Base: /api/v1. Roteador único:
server/api/v1/router.sh →
handlers/<rota>.sh. Aviso de CLI
desatualizada (lib/cli-version.sh): toda resposta
a uma CLI (UA moj[-tool]/<build>) leva
X-Moj-Cli-Status: current|outdated|dev e
X-Moj-Cli-Latest: <build> (referência =
web/moj.build, o mesmo do moj version; build =
<git-short>-<AAAAMMDD>, comparada pela data);
CLI ANTIGA (UA curl/* + Bearer, sem marcador — não lê
cabeçalho) recebe X-Moj-Cli-Status: legacy e a dica "rode
moj update" ANEXADA à error.message. Navegador e curl cru:
nada. A CLI avisa no stderr uma vez por dia.
Auth: Authorization: Bearer <token>. Respostas
JSON com envelope {success:true, …} ou
{success:false, error:{message,code}} + status HTTP
correto. Histórico e placar são TXT cru. Horários em
EPOCH. IDs validados contra path-traversal.
| Rota | Método | Auth | I/O |
|---|---|---|---|
/auth/login?contest=<c> |
POST | — | body {username,password} →
{token,logged_in,username,name,contest,server_utc}.
Contest (≠ treino) inclui o kit da submissão
OFFLINE do moj-comp:
offline_pubkey_pem (pública RSA-4096 do contest, gerada
lazy em contests/<c>/secrets/) e beacon
(carimbo de tempo assinado — ver /contest/beacon).
server_utc permite à CLI medir o desvio do relógio
local. |
/auth/status?contest=<c> |
GET | Bearer | {logged_in,login,name,contest,is_admin,is_judge,is_staff,is_cstaff,is_chief,has_photo}
(.cjudge = juiz-chefe →
is_judge:true,is_chief:true; .cstaff = chefe
de sede → is_cstaff:true, sem herdar
is_staff). has_photo existe
p/ o avatarEl NÃO pedir a foto de quem não tem: o avatar do
cabeçalho aparece em toda página e cada 404 desses é um fork de bash
(5.712 no dia 24/08/2026, 54% de todos os 404). Mesmo campo em
/index/open_training (top_users[] e
recent_solved[].user) e em
/treino/problem-stats |
/auth/logout |
POST | Bearer | {logged_out:true} — apaga o arquivo de sessão
mesmo se ela já não vale (senão o zumbi ficava p/
sempre no store) |
A porta do contest (/auth/login,
forçada pela API — o countdown do front é só conveniência):
LOGIN_ENABLED=n → 403
login_disabled; antes de
LOGIN_START_TIME → 403
login_not_open; com INSCRIÇÃO ligada
(contests/<c>/registrations.json existe) quem não
está no roster leva 403 not_registered
(janela ainda aberta),
registration_not_open ou
registration_closed — aquecimento
INCLUSO (default): só inscrito entra em qualquer rodada.
REG_WARMUP_OPEN=y no conf restaura a porta aberta durante
rodada warmup (opt-in); nesse caso a promoção da oficial
derruba a sessão de quem não se inscreveu
(reg_sweep_unregistered) e apaga o diretório vazio. Conta
de PAPEL (.admin/.judge/.cjudge/.staff/.cstaff/.mon) nunca
é barrada. Alias de TIME: se o login é membro de um
time inscrito, a credencial é a DELE mas a sessão é do
time — a resposta traz actor (quem
digitou) e is_team:true, e /auth/status,
/contest/userinfo, o access.log (5ª coluna) e
o var/actor-log guardam o ator. Ver
lib/registration.sh.
Invariante da sessão: o token só continua valendo
enquanto a CONTA existir (users/<login>/account.json
do contest da sessão ou, com USERS_FROM, o da fonte
compartilhada). Conta renomeada, removida ou contest apagado ⇒
401 auth_required na primeira requisição,
e o cliente cai no login. Sessão do MOJ não expira por
tempo: sem essa checagem uma sessão aberta antes de uma troca de handle
seguia autenticada com o login VELHO e o /submit (que faz
mkdir -p no dir do usuário) recriava o diretório do
nome antigo — resíduo sem account.json que ainda
aparecia como "solver" nas estatísticas. Ver lib/auth.sh
(_session_account_alive) e
server/bin/user-merge.sh (conserto do resíduo).
| Rota | I/O |
|---|---|
/index/news |
{news:[{id,title,date,summary,url}]} |
/index/contests?page=N |
{open:[…],upcoming:[…],closed:{items:[…],page,per_page,total}}
(cada item
{id,title,start_time,end_time,problems_count,url,scoreboard_url,report_url?}
— virtual_url
(/treino/virtual/?c=<id>) nos ENCERRADOS com o módulo
virtual ligado, ICPC e sem freeze — conveniência do card;
quem decide é o portão da API; report_url
(/relatorio/<id>/) só quando o admin PUBLICOU o
relatório estático (/contest/admin/report-publish) —
problems_count é 0 em contest
upcoming: contest por vir não revela a quantidade
de problemas, mesma regra do placar pré-início +
registration:{opens_at,closes_at,late_until,url}
quando o contest tem INSCRIÇÃO ligada — o cartão do front decide
"aberta/atrasada/encerrada" pelo relógio do cliente, sem duplicar a
regra do lib/registration.sh). Encerrados paginados
(20/pág); ?all=1 devolve todos (usado pela
página de arquivo /contests/). Contest SUPER
SECRETO (conf SECRET=1) não
aparece em nenhuma das três listas (nem no
/index/status, que também omite o nome na fila por
lista). |
/index/open_training |
{top_users:[…],recent_solved:[…],most_solved_week:[…],most_solved_prev_week:[{problem_id,problem_title,solved_count,url}],most_used_editor_prev_week:{top:{editor,count}|null,total,ranking:[{editor,count}]}}
(prev_week=resolvedores distintos por problema;
editor=mais usado nas aceitas da semana passada,
web ou editor declarado; perfil PRIVADO não
entra em top_users/recent_solved — o
filtro pula p/ o próximo). Cache var/open-training.json
por EVENTO (.score-dirty OU
.treino-list-dirty mais novos = regenera; despublicado some
da home; piso 5 min sob rajada; flock) |
| Rota | Auth | I/O |
|---|---|---|
/treino/problems |
— | array
[{id,title,tags,collections,statement_langs,solved_count,attempted_count,user_rate,difficulty,dirt,public_at?}]
(statement_langs = idiomas do enunciado, do sidecar; a
lista mostra o selo "EN ES" quando >1)
(difficulty = rótulo CANÔNICO
veasy|easy|med|hard|new pela taxa POR USUÁRIO
user_rate = resolveram ÷ tentaram — faixas .9/.7/.5 em
lib/difficulty.sh, fonte única do sistema desde a issue
#30; dirt = métrica do resolver ICPC,
(submissões de quem resolveu até o 1º AC − ACs) ÷ essas submissões, do
tries_to_ac do metrics.json; null no legado
json-count) (collections =
.moj-meta.json do pacote, um problema pode estar em várias;
public_at = epoch da 1ª publicação, vindo do índice de
donos — AUSENTE quando desconhecido; alimenta a ordenação "Novidades" do
treino). Contagens do STORE NOVO: agregação de
users/*/metrics.json
(.solved/.attempted, 1 usuário = 1 por
problema), sobreposta à base legada var/json-count/ quando
existir. Cache var/problems.json invalidado POR
EVENTO (gerador server/score/treino-list-gen.sh):
composição da lista = stamp var/.treino-list-dirty
(foreground sob flock); contagens = var/.score-dirty + piso
de 10 min (refresh em BACKGROUND, serve o stale); TTL de 60 min só rede
de segurança |
/treino/trending |
— | top-10 problemas por submissões (todas) nos últimos 7
dias (janela móvel), p/ o estado inicial do Treino Livre:
{success,window_days:7,generated_at,problems:[{id,title,count,url}]}
(ordenado por count desc). Anônimo;
problema privado NÃO entra (_private: json só em
jsons-private/). Cache var/trending.json por
EVENTO (.score-dirty/.treino-list-dirty) com
piso LONGO de 6h (varre o history de ~927 contas; a
janela é semanal) + flock |
/treino/problem?id=<id> |
— | {id,title,author,statement_html_b64,time_limits,tags,collections,languages,statement_langs,statements,samples}
(samples =
[{name,input,output}], os exemplos do enunciado como texto
— a MESMA seleção que o HTML mostra, nunca teste oculto; exemplo acima
de 4 MB vem como {name,size,too_big:true} sem os bytes;
2026-09-16) (statement_langs = idiomas do
enunciado, ["pt"] no mínimo, português primeiro;
statements =
{"<lang>":{title,html_b64}} das traduções, ausente
sem tradução — o PT segue em
title/statement_html_b64; a página mostra um
chip por idioma; ver PACOTE.md "Idiomas")
(author = arquivo author do pacote, verbatim;
vários autores juntados por , ; vazio se ausente;
collections = coleções do .moj-meta.json;
languages = ids de linguagem de submissão permitidos deste
problema, [] = todas as PADRÃO — o front filtra o dropdown
por essa lista; linguagens EXÓTICAS/opt-in
(pddl/grepe/sas/l/lpp/downward)
só aparecem quando o problema as DECLARA aqui). Registra
problem-view no log de atividade
(load_session soft: Bearer presente = login, sem =
anon; a rota segue pública) |
/treino/admin/activity-log |
GET .admin |
feed COMPLETO do treino (6 fontes no instante
exato): login (access.log) · submit (history)
· verdict (results finalized_at) ·
read (activity-YYYY-MM.log:
problem-view/log-view/source-download)
· admin (admin-audit sem ruído de máquina) ·
calib (tl-report/calib-report, EXCLUÍDO por default —
?kinds=calib inclui). Filtros
since/until (epoch), kinds (csv),
user, action, limit (≤5000).
format=csv = download do range INTEIRO
filtrado (Content-Disposition; cabeçalho
epoch,datahora,tipo,quem,acao,detalhes,ip) p/ análise
externa. Aba 📜 Atividade do admin do treino |
/treino/solvetry?user=<u> |
opc | {solved:[ids],attempted:[ids]} |
/treino/history?id=<id> |
Bearer | TXT 7 campos
tempo:user:probid:lang:verdito:epoch:subid. Veredicto
SEMPRE canônico (lib/verdict.sh;
pendentes/strings desconhecidas intactos) — o detalhe
(testes/pontos/grupos) vem do /submission/summary |
/treino/history-full?user=<u> |
opc | TXT 7 campos (todo o histórico). Veredicto canônico (idem acima) — visitante do perfil público vê só o rótulo, sem resumo (summary é só do dono) |
/treino/profile |
Bearer | GET: perfil + cota de username +
telegram:{linked,username,linked_at}
(vínculo do próprio login; sem o telegram_id) · POST
{name?,university?} |
/treino/profile/password |
Bearer | POST {old_password,new_password} |
/treino/profile/username |
Bearer | POST {new_username} →
{updated,new_username,username_changes_used,username_changes_remaining,sessions_updated}.
máx. 2/ano, cascata nos arquivos de controle
incluindo as SESSÕES
(rename_contest_sessions: TODAS as sessões daquele login —
outra aba, outro dispositivo, token do moj-cli e as de
contests que herdam os usuários via USERS_FROM — passam a
valer com o nome novo; sessions_updated = quantas; ninguém
é deslogado) e as ORGs (orgs_rename_login:
o login troca em members/admins de todas; o NOME da org — inclusive a
implícita antiga, que vira comum — não muda: é o prefixo dos ids).
A POSSE segue o rename (2026-09-18,
lib/owner-rename.sh): dono de problema
(.moj-meta.json + índice + overlay), de contest
(contests/<c>/owner), de coleção e as permissões de
criar contest passam ao login novo — o índice/contests/coleções na hora,
os metas dos pacotes (1 commit por problema) em background. Antes o
owner ficava no login antigo: os problemas sumiam de "Meus"
e, como owner também concede acesso, ficava uma posse
apontando p/ um login inexistente. O autor dos commits antigos no git
não muda. Sufixo de papel é PRESERVADO:
sufixo(novo)==sufixo(atual) — .admin troca p/
outro.admin (400 uname_role_suffix se tentar
derrubar o sufixo; uname_reserved se usuário comum tentar
assumir um) |
/treino/profile?user=<u> |
opc | GET visão pública (respeita privacidade):
{login,name,university,favorite_editor,has_photo,is_public,created_at}
(created_at=epoch de criação da conta, p/ o "membro desde"
do perfil; a visão do dono também o traz — que inclui ainda
managed:{minor,by,birthdate,note,expires_at}|null p/ conta
GERIDA); POST aceita também favorite_editor,
profile_public (400
managed_minor se conta gerida de menor tentar
tornar público). Conta gerida de MENOR é sempre privada
(profile_is_public corta perfil/foto/history/listas da
home); link-start do Telegram → 403
managed_minor; login com
.managed.expires_at vencido → 403
account_expired |
/treino/contest-registration?contest=<c> |
Bearer | INSCRIÇÃO do próprio login num contest que usa as
contas do treino (USERS_FROM=treino): individual ou em TIME
de até REG_TEAM_MAX (3) contas EXISTENTES. Fica AQUI (e não
no contest) porque o token é por ORIGEM — <id>.moj…
não enxerga a sessão do treino. GET →
{enabled, contest, contest_name, start_time, end_time, window:{state:soon|open|late|closed,opens_at,closes_at,late_until,official_start,official_round}, round_kind, gate_active, team_max, teams_allowed, me:{kind:none|individual|team,team?,cohort?,univ?,ai?,flag?}, team:{login,name,captain,members[],invited[]}|null, invites:[{login,name,captain,members}], totals}.
POST {contest,action}:
register {univ?,ai?,flag?} (a
meta pode vir junto; flag inválida = 400 SEM inscrever) ·
individual-meta
{univ?,ai?,flag?} (inscrito INDIVIDUAL declara/edita
universidade/IA/bandeira — paridade com o team-meta; vai p/
a entry do roster e materializa no .team do overlay, então
placar/🤖/bandeira funcionam igual ao time) ·
team-create
{name,univ?,ai?,flag?} ·
team-invite {login} (o
mojinho manda DM ao convidado na hora, com o link
/contests/inscricao/?c=<c> de aceitar/recusar —
lib/invite-notify.sh; best-effort: sem Telegram vinculado o
convite vale igual) ·
team-accept/team-decline
{team} · team-rename
{name} · team-meta
{univ?,ai?,flag?} (capitão: a universidade vai em
.team.univ_short — o renderer do placar exibe "[SIGLA]
Nome"; flag = país ISO-2 ou estado br-xx →
.team.flag, a bandeira do placar, 400
flag_invalid se não casar; ai = `yes |
/treino/profile/photo?user=<u> |
opc/Bearer | GET serve png 100×100 · POST {image_b64}
(redimensiona) |
/treino/editors |
— | ranking dos editores favoritos declarados
{editors:[{editor,count}],total} |
/treino/achievements |
— | registro de CONQUISTAS do perfil
{custom,version,achievements:[{id,icon,pt,en,kind,params,enabled}]}
— serve var/achievements.json (gerido pela aba 🏅 do admin)
quando válido, senão o default embarcado
(lib/achievements-default.json); avaliação é no CLIENTE.
Kinds e formato: PERFIL.md |
/treino/problem-stats?id=<p> |
— | estatísticas do problema (métricas, veredictos, por-linguagem c/
solvers distintos, editores, avatares públicos;
difficulty/user_rate/dirt
canônicos de lib/difficulty.sh — a MESMA conta da lista
/treino/problems, issue #30; acceptance_rate
continua = taxa POR SUBMISSÃO, só número, nunca rótulo) + séries
temporais (fuso America/Sao_Paulo):
daily{YYYY-MM-DD:n} (heatmap),
monthly[{m,subs,ac}],
dow_hour[{dow 0=dom,hour,n}],
first_ac_epochs[] (curva de resolvedores),
tries[{bucket,n}]+tries_median (subs até o 1º
AC), time_to_solve[{bucket,n}]+t2s_median (1ª
sub→AC),
facts{first_sub_epoch,last_sub_epoch,peak_day,first_solver{epoch,login?,name?}}
(login/nome do 1º solver SÓ se perfil público),
difficulty_percentile{harder_than_pct,cohort,success_rate}
(taxa de sucesso POR USUÁRIO vs. o acervo público do
var/problems.json; ranking com suavização de Laplace +
midrank — coorte pequena 100% não esmaga a ponta fácil; elegível = ≥5
tentantes, null se coorte <10), runtimes[{lang,t}]
(estilo Kattis: t = teste mais LENTO de cada submissão ACEITA, dos
results/<subid>.json — só era newmoj) — cache
por EVENTO (.score-dirty mais novo =
regenera; sem submissão nova vale p/ sempre; piso 2 min sob rajada;
flock) |
Desenho, regras e blindagem:
docs/VIRTUAL.md. Portão
único (vr_load, lib/virtual.sh) a
cada requisição: módulo virtual ∧ não-secreto ∧ ICPC ∧
encerrado p/ todas as sedes ∧ placar descongelado ∧ todos os
problemas públicos no treino. Portão fechado = 404
virtual_unavailable, corpo idêntico ao de contest
inexistente. O enunciado NÃO tem rota aqui: vem da pública
/treino/problem?id=.
| Rota | Método | Auth | I/O |
|---|---|---|---|
/treino/virtual/info?contest=<c> |
GET | Bearer treino | {contest,title,start_time,duration,penalty_minutes,problems_count,rules:{grace_s,max_discards,schedule_max_s},me}
— me como em /run; conta de papel recebe
me.state:"forbidden" |
/treino/virtual/run?contest=<c> |
GET | Bearer treino | {contest,duration,penalty_minutes,me:{state:none|scheduled|running|judging|finished|discarded,start,end,final,discards,discards_left,can_discard,official,result,runs:[[seg,pidx,Y|N|X|?,veredicto,subid,lang]],solved,penalty,pending,now}}.
Aplica as transições preguiçosas (fim do tempo ⇒ finaliza ou
descarta) |
/treino/virtual/run |
POST | Bearer treino | {contest,action}: start
{accept:true,at?} (422
terms_required/virtual_at_invalid; 409
virtual_already = uma vez por conta) ·
cancel (só agendada; 409
virtual_not_scheduled) ·
discard (≤ 15 min OU 0 AC, máx. 2; senão
409 virtual_locked) · finish
(com 0 AC e não-definitiva vira discard). Conta de papel: 403
role_forbidden. Resposta = a do GET |
/treino/virtual/problems?contest=<c> |
GET | Bearer treino | {problems:[{letter,id,name,languages,statement_langs}]}
— languages é a whitelist do CONTEST. Sem run largada: 403
virtual_not_started |
/treino/virtual/friends |
GET/POST | Bearer treino | Meus escolhidos: os virtuais que ESTE login quer
ver sempre no placar virtual (amigos). UMA lista por conta, p/ todos os
contests; só o próprio login lê/escreve (não há parâmetro de usuário; a
rota não fala de contest, por isso não passa pelo portão). GET =
{logins:[…],max:100}. POST
{add?:[…],remove?:[…]} (o 📌 da linha) ou
{logins:[…]} (substitui). Tira o próprio login e
duplicatas; 422 friends_invalid (login fora de
[A-Za-z0-9._@+-]{1,64}) · 422 friends_limit
(> 100). Não confere existência da conta |
/treino/virtual/feed?contest=<c> |
GET | — | {version:2,contest,title,duration,penalty_minutes,problems:[{letter,name}],views:[{id,name,unranked}],teams:[[login,flag,univ_short,nome,univ_full,guest,cohort]],runs:[[seg,tidx,pidx,Y|N|X|?]]}
— times do placar público FINAL; views/cohort
alimentam o filtro "Placar:" da página e só trazem coortes PÚBLICAS com
time no placar público (views vazio com menos de duas);
pré-gzipado; 503 virtual_feed_unavailable |
/treino/virtual/board?contest=<c> |
GET | — (Bearer marca you) |
{virtuals:[{login,name,univ,flag,start,used,solved,penalty,official,runs:[[seg,pidx,flag]],you}]}
— só participações FINALIZADAS e não removidas |
Cadastro web-first verificado pelo Telegram (1
Telegram = 1 conta; anti-duplicata). Os endpoints
verify/telegram/recover-password
são autenticados pelo token do bot
(Authorization: Bearer mojb_…, require_bot,
segredo em run/secrets/bot.token) — o bot
não loga como .admin.
| Rota | Auth | I/O |
|---|---|---|
/treino/signup/start |
público (POST) | {login?,fullname,university?} →
{nonce, deep_link, expires_at}. Valida o login (bloqueia
sufixo de papel) e cria um nonce (TTL 15 min). Não cria
conta. |
/treino/signup/status?nonce= |
público (GET) | {status: pending|created|already_linked|linked|expired, login?}
— nunca devolve a senha |
/treino/signup/verify |
bot (POST) | {nonce,telegram_id,telegram_username?,first_name?,last_name?}
→ consome o nonce (uso único), anti-duplicata, cria+vincula
(created) ou vincula conta logada (linked);
devolve {status,login,password?} (senha só p/ DM) |
/treino/signup/telegram |
bot (POST) | bot-first (/participar): {telegram_id,…} →
cria+vincula ancorado no telegram_id (idempotente) ou
already_linked |
/treino/recover-password |
bot (POST) | {telegram_id} → resolve o login pelo vínculo, gera nova
senha → {status:ok|not_linked,login?,password?} |
/treino/telegram/link-start |
Bearer | conta logada gera nonce purpose:link p/ vincular o
próprio Telegram (ex.: .admin receber alertas) →
{nonce,deep_link,expires_at}. UI: seção 📨
Telegram do perfil |
/treino/telegram/unlink |
Bearer | POST {} — desvincula o Telegram do PRÓPRIO login (404
not_linked sem vínculo). Cota anti
conta-descartável: usuário comum desvincula no máx
TELEGRAM_CHANGE_LIMIT (1)/ano (403
telegram_limit com a data da próxima; histórico em
account.json telegram_changes); .admin
é livre. Trocar de Telegram exige desvincular ⇒ a cota cobre a
troca. A cota sai no GET /treino/profile
(telegram.changes_used/limit/remaining/next_available;
limit:null = livre) |
.admin, Bearer)Acesso registra IP
(X-Forwarded-For/REMOTE_ADDR) e
User-Agent na sessão e em
var/access.log.
| Rota | Método | Ação |
|---|---|---|
/treino/admin/sessions |
GET | sessões ativas
{count,sessions:[{login,name,ip,user_agent,login_at}]} |
/treino/admin/managed-users |
GET | contas GERIDAS (menores, sem Telegram — CONTAS-GERIDAS.md):
{users:[{login,fullname,by,note,birthdate,minor,expires_at,disabled,created_at}]} |
/treino/admin/managed-create |
POST | cria contas geridas
{users:[{fullname,birthdate,login?,note?,expires_at?}]}
(1..500; login vazio = slug do nome com dedup; sufixo de papel recusado)
→
{created:[{login,password,fullname,birthdate}],skipped:[{…,reason}]}
— senhas só nesta resposta; audit
managed-create |
/treino/admin/managed-reset |
POST | {login} (só gerida) → senha nova (user_genpass)
devolvida UMA vez + derruba sessões; audit
managed-reset |
/treino/admin/managed-update |
POST | {login, note?, birthdate?, expires_at?|null, disabled?}
— edita .managed; disabled:true =
senha-sentinela !…+derruba sessões;
disabled:false = reabilita com senha nova devolvida; audit
managed-update |
/treino/admin/managed-remove |
POST | {login} (só gerida) → mv p/
.removed-users/<login>-<epoch>; audit
managed-remove |
/treino/admin/achievements |
POST | salva o registro de conquistas do perfil:
{achievements:[…]} (valida ids únicos
[a-z0-9-], kind conhecido, params por kind; grava atômico
var/achievements.json; audit
achievements-save) ou {restore_default:true}
(remove o registro; volta ao default). Erro de validação = 400
achievements_invalid com a mensagem. Aba 🏅
Conquistas do admin do treino; doc: PERFIL.md |
/treino/admin/access-log?day=YYYY-MM-DD |
GET | log de acessos (filtra por dia) |
/treino/admin/queue |
GET/POST | pendentes por lista + calibração
{total_pending,spool_queued,calib_pending,calib_inflight,calib_targeted,lists:[{contest,name,pending}], routing, pending_details}
(routing = roteamento do ESCRITOR com shards:
{shards,workers:[{shard,alive_age_s,in_submit,in_results,in_other}],orphans,queue_depth,assigned,delivered_5m}
— alive_age_s:-1 = worker do shard nunca bateu;
orphans = arquivos em s<j> com
j>=K, mismatch de JUDGED_SHARDS entre API e
daemon) (calib_pending = fila de calibração
kind=calibrate, separada de index;
calib_targeted = recalibrações direcionadas por host).
&details=1 →
pending_details:[{contest,login,problem,lang,id,since,age_s,state,has_source}]
— CADA submissão pendente, com estado no pipeline (`no-spool |
/treino/admin/judges |
GET | máquinas de juiz (modelo pull)
{online,busy,machines:[{host,online,busy,status,langs,cage_root,cache,tl,current,current_jobs,queued_calibrate,slots,partition,topology,config,report}]}
— current_jobs = TODOS os jobs em execução (multi-slot; UM
por slot ocupado, com since = epoch do claim;
current = o 1º, compat — a UI da fila itera
current_jobs); status = auto-relato do agente
novo (ok|draining|disabled, null
= agente antigo) e busy-sem-job vira
[{kind:"draining"|"disabled"|"unknown_busy"}];
slots:{free,total}; partition = vigente no
agente; config = a config DESEJADA (judges-config) ou null;
cache = pacotes em disco do juiz (não
RAM); report.gpu = GPU de compute
comprovada ({vendor:nvidia|amd,names}) ou
null |
/ops/judge-config |
GET ?host= / POST
{host, partition?:off|numa|cpus:<X>, reserve?, disabled?} |
(admin) config fina POR JUIZ (multi-slot):
particionamento da máquina em slots com pinning, cpus reservadas e
desabilitar (drena). Vive em
contests/treino/var/judges-config.json; o heartbeat entrega
ao agente quando muda (cfg_hash) e o agente aplica após
DRENAR os jobs em andamento. CLI: moj judges config |
/ops/judge-reset |
POST {host, action?:kill|restart} |
(admin) RECUPERAÇÃO sem SSH: kill
(default) manda o agente SIGKILL-ar o grupo de processos de cada slot
(job inteiro), reportar judge-error/calib-fail (nada espera TTL) e
reconciliar a config; restart = kill + o agente se
re-executa (register boot:true re-enfileira o que estava
atribuído — fila não se perde). Entregue no próximo heartbeat MESMO com
o juiz ocupado/desabilitado. CLI:
moj judges reset/restart |
/ops/calib-cancel |
POST {id, inprogress?:false} |
(admin) cancela calibrações do problema na fila:
remove pendentes + direcionadas não entregues →
{removed_pending,removed_targeted,removed_inprogress,inflight};
as EM EXECUÇÃO só com inprogress:true (senão só contadas em
inflight — prefira judge-reset). CLI:
moj judges cancel |
/ops/judge-results |
GET ?host=&limit= |
(admin) relatório de correções por juiz: últimas N
correções (run/results/, com host/verdict/duração) +
agregado
by_host:{total,accepted,judge_errors,avg_duration,last_at}.
CLI: moj judges results |
/ops/judge-cache |
POST {host, action?:clearcache} |
(admin) limpa o cache local de pacotes de um juiz:
enfileira um comando POR-HOST que o agente pega no próximo heartbeat
(quando estiver livre), apaga o $JUDGE_CACHE e se
re-registra com inventário vazio. Não bloqueia — devolve
{action,host,cmdid,status:"queued"} e o efeito aparece no
/judge/list. Use quando um juiz ficou com pacote
velho/corrompido em cache |
/treino/admin/stats |
GET | {users,active_sessions,problems:{total,public,private},by_author:[{author,owner,total,public,private}],problems_public_by_day:[{day,count}],logins_per_day,submissions_per_day}
— contagens da plataforma (privados contados, não
listados); problems_public_by_day alimenta o mapa
de calor de entrada de públicos (data aproximada; ver
public_at) |
/treino/admin/response-stats |
GET | tempo de resposta + volume (cacheado):
{coverage, overall, per_day, by_dow_hour, subs_per_day:[{day,count}], subs_by_dow_hour:[{dow,hour,n}]}.
Tempo só de submissões com finalized_at;
volume conta TODAS as linhas do history. EPOCH/UTC |
/treino/admin/calib-activity |
GET | volume de calibrações no tempo (cacheado; do log
run/updates/log):
{calib_per_day:[{day,count}],calib_by_dow_hour:[{dow,hour,n}],total}.
run/ pode rotacionar → histórico parcial |
/treino/admin/logout-user |
POST | {login} ou {logins:[…]} → remove as
sessões (um ou vários) |
/treino/admin/lock-user |
POST | {login} ou {logins:[…]} →
trava (troca a senha por aleatória) + desloga |
/treino/admin/logout-ip |
POST | {ip} → encerra todas as sessões daquele IP
(IPv4/IPv6) |
O FORMATO do pacote (arquivos,
.moj-meta.json,.moj-id), o que são ORGs e COLEÇÕES e o ciclo validar → calibrar → publicar estão em PACOTE.md (fonte única). Aqui ficam só as rotas. Roteiro de montar um pacote:mojtools/README.md.
Backend = repo git LOCAL por problema
(MOJ_PROBLEMS_DIR/<org>/<prob>, o servidor
commita direto via problem_commit; sem serviço externo),
mas o autor só usa o login do MOJ (sem chave/git).
Listagens leem o índice de donos
contests/treino/var/problem-owners.json (gerado por
mojtools/gen-problem-owners.sh; regen em background, TTL
PROBLEM_OWNERS_TTL_MIN). O índice é a fonte
única: todo problema tem owner (login). Problema
sem dono (legado não-migrado) é ignorado no índice;
/mine = owner==login (sem casamento difuso).
Não há mais "legado".
Controle de acesso — garantido na API, NUNCA só na interface. A fronteira é a ORG: ver o source/pacote/soluções/calibração e editar/operar é p/ MEMBRO da org (
require_problem_edit=org_is_member) — sem atalho de.admin. Ver o detalhe/statement (get/validation) é membro da org ou se o problema é público (require_problem_view). Membro da org VÊ TODOS os problemas dela, inclusive privados, em toda listagem/painel (decisão 2026-07-16); problema PRIVADO não é nem LISTADO p/ quem não é membro da org nem colaborador por-problema (as listagens pré-filtram emowners_emit), inclusive p/.admin— provas em elaboração não podem vazar. Não-autorizado recebe 404 (não revela a existência). Helpers centrais emlib/problems.sh;moj-cli/curl batem na mesma API e não burlam.
| Rota | Método | I/O |
|---|---|---|
/problems/mine |
GET | {problems:[{id,title,author,owner,collections,public,html,claimed}]}
— claimed=true se owner==login, senão
"provável" (nome casa) |
/problems/shared |
GET | problemas compartilhados com o login: tudo que ele pode editar e não é dele — membro da org OU colaborador por-problema (não dono) |
/problems/public |
GET | problemas públicos (no treino livre) — visão de gestão (dono/autor) |
/problems/collection?name=<c> |
GET | problemas da coleção (curso/diretório, ex.:
obi-problems) |
/problems/collections |
GET | {collections:[{name,count,public,owner,mine,can_manage}]}
— coleções = TAGS curadas (do registro), com contagem
visível. Coleção (agrupamento, m:n) ≠ ORG (acesso, 1:1
— ver /orgs/*) |
/problems/collection |
GET ?name |
problemas de uma coleção (filtra pela tag
collections) |
/problems/get?id=<id> |
GET | detalhe: índice + validation (relatório do portão) +
statement_html_b64/tags +
time_limits (EFETIVO) /
time_limits_calibrated / tl_override.
⚠ O TL vem do pacote (tl_store_served,
override aplicado), não do json servível: o json público só existe
depois de publicar — em problema privado (o estado de
quem está calibrando) o campo sumia e o editor caía num fallback que
mostra o máximo CRU entre juízes — e
edit/upload não reindexam, então mesmo público
o número podia estar velho. O checksum vem materializado do índice,
então não há hash de pacote por request. O índice inclui
languages (whitelist de submissão do
.moj-meta.json; [] = todas as padrão) — a
gestão exibe no detalhe (badges + atalho p/ o widget do editor) |
/problems/validation?id=<id> |
GET | último relatório de validação
{checks:[{name,ok,detail}],html_built,render_warnings,ok} |
/problems/status |
GET | painel dos problemas do login
(dono+colaborador+membro da org; privado de org alheia
não aparece — owners_visible):
{total,counts:{validated,…,needs_recalibration,good_sol_no_tl,public_unvalidated,needs_review,errors},calibrating_ids,attention_ids,problems:[{id,title,owner,author,public,validated,calibrated,being_calibrated,stale,needs_recalibration,good_sol_no_tl,good_sol_missing_langs,public_unvalidated,error,needs_review,review_reasons,time_limits,time_limits_calibrated,tl_override,updated_at}]}.
time_limits é o EFETIVO (com o
TLOVERRIDE do conf aplicado — o override vem carimbado no
índice de donos por gen-problem-owners.sh, então o Painel
não abre pacote nenhum); time_limits_calibrated é o cru dos
juízes e tl_override é o declarado ({} sem override).
good_sol_no_tl = tem solução good sem TL (linguagem
suportada que falhou em TODOS os juízes); needs_review =
precisa revisão (erro / good sem TL / público não validado ou não
calibrado). stale/needs_recalibration do
checksum do índice (≤30 min); sem hash de pacote por
request; TL/validação vêm dos sumários
por-evento run/{tl,validation}-summary.json
(upsert pelos escritores; sem varrer run/tl por request) |
/problems/tl?id=<id> |
GET | time limits ao vivo (recomputa o checksum agora) +
stale/needs_recalibration exatos:
{problem,checksum,time_limits,time_limits_calibrated,tl_override,calibrated_checksum,hosts,updated_at,calibrated_at,calibrated,being_calibrated,stale,needs_recalibration}.
time_limits = o EFETIVO (o que o aluno vê
e o juiz honra): com TLOVERRIDE no conf do pacote,
override[lang] // override[default] // calibrado[lang];
time_limits_calibrated = o cru dos juízes;
tl_override = o declarado no conf ({} sem
override). being_calibrated = há
calibração pendente/em execução p/ este problema AGORA
(mesma varredura do painel) — distingue "TL vazio porque acabou de
enfileirar (validate/calibrate)" de "calibrou e não obteve TL". Quando
needs_recalibration, explica o PORQUÊ:
reason (checksum velho→novo),
changes = commits desde a calibração que
tocaram os caminhos que afetam o TL (conf/tests-input/sols-good/scripts
— o que o tl-checksum cobre; [{sha,at,author,subject}],
≤20) e changed_files (≤30). Acesso: membro da org
ou público (require_problem_view;
404 senão). Versão não-admin do
/ops/problemtl. Python é UMA linguagem:
py (pypy3) — chaves
py3/py2 legadas são fundidas em
py nos time_limits servidos (o cru de
hosts pode ainda trazê-las até recalibrar) |
/problems/recalibrate-stale |
POST {} | {ids:[...]} |
recalibra em LOTE tudo que "precisa recalibrar" no
painel do login (calibrado + checksum divergente — mesma conta do
/problems/status); ids restringe (intersectado
com o conjunto AUTORIZADO — a fronteira é owners_visible,
nunca o input). Cada item via cal_request (idempotente +
serializado por-problema no claim — lote é seguro). Resposta
{count, queued:[{id,reqid}]}. Web: botão "⚙ Recalibrar
todos (N)" no Painel; CLI: moj calibrate --all-stale |
/problems/calib?id=<id> |
GET | calibração por juiz (membro da org):
{id,checksum,good_langs,missing_langs,tl_override,time_limits,time_limits_calibrated,hosts:[{host,tl,missing,at,log,reports,sols}]}.
⚠ hosts[].tl e sols[].tests[].tl são a
MEDIÇÃO da calibração, nunca o override — o calibreitor roda
com MOJ_CALIBRATING=1 justamente para medir de verdade;
time_limits (efetivo) existe para o cartão poder dizer
o julgamento usa outro número. missing_langs =
linguagens good sem TL em nenhum host (solução good
falhou em TODAS as máquinas); hosts[].missing = faltantes
naquele juiz. sols = a calibração POR
EXTENSO daquele juiz, estruturada p/ ferramentas externas:
[{file,lang,category:good|pass|slow|wrong,verdict,tests:[{name,code,time,tl}]}]
— o MESMO formato do vetor tests de uma submissão normal
(nome do teste, código curto AC/WA/TLE/…, tempo em s, TL usado);
[] = juiz ainda não reportou o vetor (mojtools/agente
antigos — o log texto continua). tl_override =
o TLOVERRIDE do conf do pacote (ver PACOTE.md),
{} sem override. Sols .py2/.py3
legadas contam como py |
/problems/calib-report?id=<id>&host=<host>&name=<name> |
GET | o report.html rico (o do
build-and-test) de UMA solução, como saiu da calibração
NAQUELE juiz
(run/calib/<id>/r/<host>/<name>.html). Os
nomes válidos vêm de hosts[].reports do
/problems/calib. Devolve HTML, não JSON.
Acesso: require_problem_edit — dono/colaborador, sem atalho
de .admin (é código de solução). CLI:
moj calib-report |
/problems/my-stats |
GET | análise dos problemas do login (dono+colaborador)
agregada em TODA a plataforma (treino + turmas; cache precomputado).
{totals:{owned,with_activity,attempts,accepts,solvers},overall_verdicts:[{verdict,count}],overall_languages:[{lang,submissions,accepted}],most_popular:{id,title,attempts},problems:[{id,title,attempts,accepts,wrong,acceptance_rate,distinct_users,solvers,contests_count,verdicts,languages,first,last}]}.
Só os problemas do login; sem logins, sem nomes de
contests (só contests_count) — não vaza prova
privada |
/problems/judges |
GET | o parque de juízes para a calibração DIRECIONADA do
editor:
{judges:[{host,cpu,arch,langs,cage_root,last_seen,online}]},
ordenado por online › cpu › host (online = heartbeat nos
últimos 30 s). O editor agrupa por cpu para oferecer "1 por
processador". Só exige login (é inventário de máquina, não conteúdo de
problema). CLI: moj calibrate --judges |
/problems/validate |
POST {id} |
portão de qualidade, NÃO publicação: valida (portão
estático: HTML compila + seções
## Entrada/## Saída + exemplos pareados) +
gera o índice + pede calibração a um juiz (que roda as
good e reporta o TL). NÃO mexe no
public — problema privado continua privado
(publicar é /problems/set-public, que checa a trava da
ORG). Relatório: /problems/validation. Só membro da
org. |
/problems/publish |
POST {id} |
DEPRECADO — alias de
/problems/validate (o nome fazia parecer que validar
publicava) |
/problems/request-calibration |
POST {id, hosts?:[...]} |
enfileira calibração (juiz roda
calibreitor.sh, gera tl.<host>).
IDEMPOTENTE: se já existe calibração pendente/em
execução p/ o id, devolve o reqid existente com
status:"already_queued" (nunca duplica job — lição do
incidente 2026-07-15); direcionada (hosts) dedupa por host
os comandos ainda não entregues (hosts[].status) |
problem_commit)| Rota | Método | I/O |
|---|---|---|
/problems/repos |
GET | diretórios/orgs de que o login é membro
{repos:[{repo,owner,collaborators,collections,mine}]} |
/problems/repo-create |
POST {repo, collections?} |
cria o diretório (org no namespace do login; provisiona a org implícita lazy) |
/problems/source?id=<id>[&tests=meta|full] |
GET | source editável
{editable,title,titles,statement_langs,translations,enunciado_md,enunciado_format,author,tags,conf_text,public,collections,languages,examples,tests,sols{good,slow,wrong,pass,upcoming},score,score_text,editorial_md,scripts,scripts_files,docs_files}
(translations =
{"<lang>":{title,enunciado_md,editorial_md?,notes?:{"<sample>":md}}}
das traduções
docs/enunciado.<lang>.md/solucao.<lang>.md/notes/<sample>.<lang>.md;
titles =
{"<lang>":título} do meta;
statement_langs = ["pt",…];
PT segue nos campos de sempre — 2026-09-15) SÓ MEMBRO da
org (require_problem_edit); não-autorizado recebe
404 (sem read-only, sem atalho de .admin).
Cada examples[i] traz explanation (opcional);
editorial_md = resolução só p/ setter; scripts
= caminhos relativos de scripts/ (árvore do editor web);
scripts_files = ROUND-TRIP da correção
especial — [{path,content_b64,exec} | {path,symlink}]
(base64 suporta binário; symlink cobre os drivers interativos
scripts/<lang> -> c);
score_text = tests/score cru
(round-trip byte-fiel do moj push/clone);
languages = ids de linguagem de submissão
permitidos (.moj-meta.json, [] = todas);
docs_files = ROUND-TRIP das IMAGENS de
docs/ — [{name,content_b64}] (figuras do
enunciado/notas; nomes simples com extensão de imagem).
examples[i].explanation vem de
docs/notes/<sample>.md (formato de autoria) ou do
legado sample-notes.json. tests=meta
(DEFAULT): os testes OCULTOS saem sem conteúdo —
{name,size_in,size_out,omitted:true} — e a resposta traz
tests_omitted:true; o conteúdo de um teste vem por
/problems/test. Motivo: problema com testes grandes (OBI:
inputs de 12 MB) gerava corpo de centenas de MB (52 s medidos) e o
editor web ficava todo esse tempo com o formulário VAZIO, idêntico ao de
"problema novo". tests=full devolve o
conteúdo (é o que o moj clone usa — round-trip). Ao salvar,
teste com {name, keep:true} preserva o
conteúdo que está no servidor (o editor manda isso para os testes que
não baixou) |
/problems/preview |
POST
{enunciado_md, enunciado_format?, examples?, title?, id?, images?, lang?}
ou {kind:"editorial", markdown, id?, images?, lang?} |
pré-visualização HTML (= o renderizador único
render-statement.sh, idêntico ao servido) — injeta o
título (h1) e os exemplos (cada
explanation renderizada em markdown com embed).
lang
(pt|en|es, default
pt; 400 lang_invalid) = rótulos dos exemplos
(Exemplos/Examples/Ejemplos…) e <html lang>; o
cliente manda texto e explicações JÁ no idioma (o HTML dos exemplos sai
do stmt_samples_html do mojtools, o MESMO do índice).
kind:"editorial" renderiza só o markdown,
sem exemplos e sem h1 — o botão Pré-visualizar da aba Resolução.
Resposta {html_b64, lang, kind}. Imagens-arquivo
aparecem: com id (exige
require_problem_edit) as imagens de docs/ do
pacote são semeadas no render; images:[{name,content_b64}]
(≤16, nomes de imagem saneados) cobre figura ainda não enviada →
{html_b64} |
/problems/download?id=<id>[&sha=<sha>] |
GET | baixa o pacote .tar.gz (inclui
soluções → membro da org); com sha, a versão
daquele commit (git archive, worktree intocado);
stream binário |
/problems/test?id=<id>&name=<teste> |
GET | conteúdo de UM teste {name,input,output} — o par do
tests=meta; mesmo gate do source (só membro da org; 404 p/
os demais) |
/problems/test-run |
POST {id, filename, code_b64} |
roda UMA solução avulsa NO JUIZ (autoria): job real
na fila (banda lista-privada, contest sentinela
_testrun), mesma jaula e mesmo TL da submissão de aluno,
sem tocar history/placar de ninguém →
{run:<32hex>, status:"queued"}. Gate: membro
da org (require_problem_edit, 404 — rodar contra
os testes ocultos revela o problema). Teto SUBMIT_MAX_KB
(413); linguagens aceitas = PLATAFORMA ∪ languages
do pacote (a whitelist de SUBMISSÃO do problema não vale aqui —
autor testa o que quiser que rode); rate: máx 3 runs queued
por login (429 testrun_busy); auditado
(test-run). Registro em run/testrun/ com TTL
de 7 dias (GC preguiçoso) |
/problems/test-run?run=<32hex> |
GET | polling do test-run:
{run,problem_id,filename,lang,status:queued|done,requested_at}
e, quando done,
+{verdict,verdict_canon,score,correct,total_tests,duration_s,tl_used,tests:[{name,code,time,tl}],finished_at,report:bool}
— o vetor tests é o MESMO da submissão normal. Gate pelo
problema DO REGISTRO (membro da org; 404) |
/problems/test-run-report?run=<32hex> |
GET | o report.html do test-run (HTML; 404 enquanto
julga/expirado). Mesmo gate do registro |
/problems/history?id=<id>[&limit=N][&sha=<sha>] |
GET | histórico git do problema (membro da org — expõe
soluções/testes). Sem sha:
{id,commits:[{sha,at,author,subject,files,insertions,deletions}]}
(limit≤200, default 50). Com sha: o
git show -p →
{sha,at,author,subject,truncated,diff_b64} (diff limitado a
400 KB) |
/problems/restore |
POST {id, sha, confirm} |
restaura o problema ao estado do commit
sha como um COMMIT NOVO (história nunca é
reescrita; confirm repete o sha). O
.moj-meta.json (público/coleções/owner) é
PRESERVADO — meta antigo não republicaria prova
privada. Sem revalidação/recalibração automática (igual ao edit). Membro
da org |
/problems/upload |
POST {id|repo,prob, tar_b64} |
sobe um pacote
(.tar/.tar.gz/.tar.bz2/.tar.zst/.zip)
e substitui o conteúdo (commit). Do
.moj-meta.json do tar lê os campos de CONTEÚDO —
display_title, collections,
languages (ausente/[] ⇒ preserva); os de
ACESSO (public/public_at/owner)
nunca vêm do tar. Tar sem o arquivo tags ⇒
preserva as do servidor (curadoria); com (mesmo vazio) ⇒ substitui |
/problems/export?id=<id> |
GET | baixa o problema como pacote ICPC/Kattis (2025-09)
.tar.gz (problem.yaml+statement+data+submissions); inclui
soluções → exige escrita/admin
(mojtools/kattis/export.sh) |
/problems/import |
POST {repo, prob?, tar_b64} |
importa um pacote ICPC/Kattis
(mojtools/kattis/import.sh) → cria um problema MOJ julgável
(checker custom via bridge); exige permissão de criação. Round-trip sem
perda via .kattis.json |
/problems/create |
POST
{repo,prob,enunciado_md?,author?,tags?,examples?,good_sol?,title?,collections?,languages?,...} |
cria problema novo; commit+push; {id,sha}.
prob = slug minúsculo
^[a-z0-9][a-z0-9._-]{1,80}$ (400
prob_invalid); collections tem de EXISTIR no
registro curado (400 coll_unknown — MESMA trava do
edit/set-collections; a homônima da org é isenta).
languages = ids permitidos de submissão
([]/ausente = todas) |
/problems/edit |
POST {id, ...campos} |
edita (só campos presentes); commit+push autorado. Aceita
translations
({"<lang>": {title?, enunciado_md?, editorial_md?, notes?:{"<sample>":md}} | null}
— idioma ausente = intocado; null = apaga
enunciado/editorial/notas/título do idioma; dentro do idioma, campo
ausente = intocado e "" = apaga; notes
presente SUBSTITUI as notas daquele idioma; só
en/es) e titles
({"<lang>":título}, mesclado no meta; o servidor só
guarda idioma com arquivo). Salvar as
examples[].explanation PT nunca apaga nota traduzida.
Aceita languages (ids de submissão
permitidos no .moj-meta.json; ausente = não toca,
[] = limpa/todas). Aceita também
scripts_files (SUBSTITUI
scripts/ inteiro quando presente — paths validados, sem
.., confinado a scripts/, exec
vira +x, symlink recriado se o alvo resolvido fica dentro
de scripts/; campo ausente = não toca) e
score_text (grava tests/score
verbatim; "" remove). Aceita
docs_files (SUBSTITUI as imagens de
docs/ quando presente — nomes saneados, só extensão de
imagem, cap ~3MB; ausente = não toca).
examples[].explanation grava
docs/notes/<sampleN>.md (1 markdown por exemplo — e
REMOVE o legado sample-notes.json). Mexer em
scripts/ muda o tl-checksum ⇒ recalibração. O
editor web gere a correção especial na sub-aba "⚙
correção" (Soluções & Correção — lista + templates) e envia
scripts_files no save |
/problems/script-templates |
GET | templates de corretor especial (lidos de
mojtools/script-templates/<key>/ — criar template =
criar uma pasta lá):
{templates:[{key,name,description,conf_hints,files:[{path,content_b64,exec} | {path,symlink}]}]}
— files no MESMO shape do scripts_files
(aplicar = preencher a seção da UI e salvar). Symlink externo do
template (drivers canônicos do mojtools) vem RESOLVIDO como conteúdo;
symlink interno (cpp -> c) vem como symlink. Iniciais:
checker-testlib, interativo,
interativo-rank, compare-float,
ban-funcoes-c |
/problems/delete |
POST {id, confirm} |
REMOVE o problema (git rm da subpasta + push) e do
treino. Destrutivo: confirm tem de repetir
EXATAMENTE o id. Dono/colaborador ou admin |
/problems/set-public |
POST {id, public:bool} |
público on => valida + calibra
(index_problem_bg no servidor; só entra no treino se o
portão passar) e grava public no
.moj-meta.json; off => sai do treino na
hora. A calibração só entra na fila se o pacote MUDOU
desde a última calibrada (tl-checksum atual ≠ checksum do store servido)
— resposta traz calibration:"queued"|"up_to_date"; publicar
em massa sem mudança não enfileira recalibração redundante |
/problems/set-collections |
POST {id, collections:[...]} |
define as coleções (tags) do problema no
.moj-meta.json; valida contra o registro
(curada: a coleção tem de existir) |
/problems/move |
POST {id, to_org} |
move um problema de rascunho p/ outra org (muda o
id <org>#<prob>); bloqueia se
público/em uso (senão órfãoria o histórico); exige ser membro
das DUAS orgs |
/problems/repo-collaborators |
GET ?repo / POST {repo,add?,remove?} |
compartilha o diretório (membro da org; só o dono
gerencia). Cada login em add precisa existir no
treino e poder criar problemas
(cc_can_create) — senão 422 login_invalid/404
user_notfound/403 cannot_create, recusa
ATÔMICA; remove não valida |
/problems/collection-create |
POST {name} |
cria uma coleção (TAG) no registro curado. Nome é TEXTO LIVRE (pode ter espaços/acentos — é só rótulo). Exige permissão de criação; criador = dono. (NÃO é org: acesso é por org) |
/problems/collection-rename |
POST {name, to} |
renomeia a coleção: registro NA HORA + re-tag dos N problemas em
BACKGROUND (retag:"background", devolve
retag_job p/ acompanhar; síncrono
estourava o timeout do nginx). RETOMADA:
name inexistente + to existente = bulk
anterior morreu ⇒ repete só o retag (resumed:true). Só dono
ou .admin |
/problems/collection-delete |
POST {name} |
exclui a coleção: untag dos N problemas em
BACKGROUND (devolve
retag_job) e o registro só sai NO FIM
(untag:"background"; morreu no meio ⇒ a coleção ainda
existe, repetir o delete RETOMA). Só dono ou .admin |
/problems/collection-retag-status?[job=<id>][&name=<coleção>] |
GET | situação dos jobs de retag (rename/delete):
{jobs:[{id,from,to,by,started_at,total?,done,failed,finished_at?}]}
mais novos primeiro (últimos ~50); sem finished_at =
rodando (done/total = progresso, total é
estimativa). job= filtra pelo id devolvido em
retag_job; name= por from/to |
source/create/editcobrem o pacote inteiro:title(vem do campo, não de% Títulono texto — o render injeta o h1),enunciado_md,conf_text(TL/ulimits/STOPWHEN/…, versaad-problems/README.org),examples(sample; cada um aceitaexplanationopcional →docs/sample-notes.json, mostrada após o exemplo),tests(ocultos),solspor categoria{good,wrong,slow,pass,upcoming}(cada[{filename,code}]),score(grupos de pontuação; cada grupo tem{name,weight,glob}e oglobpode ser uma lista", "-separada de padrões, ex.:g2_*, g3_*) eeditorial_md(resolução em markdown →docs/solucao.md, só p/ setter, não vai ao aluno).
Quem pode criar (problemas/pastas/coleções) = mesma regra de criar contest (
cc_can_create:.adminou allowlist ou ≥ N resolvidos, menos a denylist) — gerida em/treino/admin/contest-perms.create/repo-create/collection-create/upload-novo exigem isso; editar/compartilhar problema existente continua por colaborador (org_is_member).
Conceito completo (ORG = acesso, COLEÇÃO = agrupamento, e por que são ortogonais): PACOTE.md.
Storage = repo git local por problema
(MOJ_PROBLEMS_DIR/<org>/<prob>), e o acesso é
por ORG (o <org> do id
<org>#<prob>): quem é membro
escreve em qualquer problema da org; a org tem uma trava de
público (public_allowed, privada por PADRÃO →
problemas nunca ficam públicos: anti-vazamento de prova), e só
admin da org a muda. Cada usuário tem uma org
implícita <login> (sempre privada).
Registro: contests/treino/var/orgs.json
(lib/orgs.sh).
| Rota | Método | Descrição |
|---|---|---|
/orgs/list |
GET | orgs de que o login é membro (inclui a implícita,
criada aqui):
{orgs:[{name,title,members,admins,public_allowed,implicit,count,public,mine,can_manage}]}.
Não lista org alheia |
/orgs/get |
GET ?name |
detalhe de 1 org; só membro/admin ou .admin global,
senão 404 (não vaza existência) |
/orgs/create |
POST
{name,members?,admins?,title?,public_allowed?} |
cria org; o criador vira membro+admin (exige
cc_can_create, a regra de criar contest). Cada login de
members/admins precisa existir no
treino e poder criar problemas — 422/404/403
senão (recusa ATÔMICA: a org nem nasce) |
/orgs/members |
GET ?name / POST
{name,add?,remove?,admins_add?,admins_remove?} |
só admin da org (ou .admin) gerencia; criador blindado;
org implícita não tem gestão. add/admins_add
validam cada login (existe no treino + cc_can_create; 422
login_invalid/404 user_notfound/403
cannot_create, atômico);
remove/admins_remove não validam (lixo já
gravado precisa poder sair) |
/orgs/set-public-allowed |
POST {name,public_allowed:bool} |
liga/desliga a trava (só admin da org; implícita ⇒
409). Desligar DESPUBLICA em cascata
os problemas públicos da org (tira do treino) — resposta traz
unpublished |
/orgs/delete |
POST {name} |
remove uma org VAZIA (sem problemas — conferido em
disco); só admin da org (ou .admin); org
implícita ⇒ 409
implicit_org; org com problema ⇒ 409
org_not_empty |
O CLI moj (web/moj,
servido em GET /moj; fonte em moj-cli/) usa
essas rotas para autoria sem git/sem chave:
moj new/clone/push/publish/share/org/mv. Storage
MOJ-nativo: o servidor commita no repo git LOCAL de cada problema
(MOJ_PROBLEMS_DIR/<org>/<prob>).
Permissão de escrita = membro da ORG do problema (
org_is_member; sem atalho de.admin). Visibilidade imediata via overlaycontests/treino/var/authored.json(mesclado ao índice). Público só se a org permitir (public_allowed) — camada anti-vazamento de prova.
| Rota | Método | Auth | I/O |
|---|---|---|---|
/contest/beacon?contest=<c> |
GET | Bearer | → {beacon,server_utc}. Beacon de tempo
p/ a submissão offline (moj-comp):
payload_b64.sig_b64, payload {v,c,l,t,n}
assinado RSA-PSS com a chave do contest. A CLI re-ancora a cada comando
com rede; o beacon embutido no pacote offline prova que ele nasceu
depois de .t (piso do carimbo). Ver
lib/contest-offline.sh e FLOW.md §offline. |
/contest/offline-submit?contest=<c> |
POST | Bearer | body {packets:["<pkt-json>",…]} (máx 50).
Rota emergencial do moj-comp: pacotes
cifrados (RSA-OAEP+AES-256-CBC com sha do conteúdo no envelope) criados
SEM rede. Valida por pacote: decripta; v/login/contest
conferem; beacon assinado do mesmo login/contest;
beacon.t ≤ claimed_utc ≤ now+30s; claimed na janela DO
aluno (start…fim efetivo, extend conta); claimed monotônico vs último
aceito; dedup por sha256; extensão na whitelist de linguagens do
problema (mesma regra do /submit; fora dela =
pacote rejected na chegada). Aceito ⇒ spool com
time=claimed (contabiliza no horário
reivindicado — placar/penalidade usam sub_epoch) +
var/offline-log + audit (offline-submit, com
gaps beacon→claimed→chegada p/ o organizador adjudicar). →
{results:[{sha,status:accepted|rejected|duplicate,…}],accepted,rejected} |
/submit?contest=<c> |
POST | Bearer | body {problem_id,filename,code_b64,source?}
(source=web|file) →
{submission_id,status:"queued"} (não bloqueia). A
linguagem é a extensão, CANONICALIZADA na porta
(lang_canon_ext, lib/langs.sh): C++ =
.cpp, .cc, .cxx,
.c++ (e .hpp) → CPP;
.h → C; .py2/.py3 →
PY. É o canônico que vai ao spool (lang), ao
history, ao archive (submissions/<id>.cpp) e ao juiz
— antes ia a extensão crua (CC) e o julgador morria em
"Language 'cc' not availale" (2026-09-14). O filename
mantém a extensão original. O filename é
NORMALIZADO pelo servidor (safe_src_filename): o
cliente manda o que quiser, o juiz recebe um nome sadio — sai o caminho,
saem espaços, sai o (N) que o navegador gruda em download
repetido (l(1).cpp → l.cpp) e saem os
metacaracteres de shell/make; acento é preservado (em Java o arquivo tem
de casar a classe pública). Sem isso o mesmo código dava AC como
l.cpp e Compilation Error como
l(1).cpp — o nome chega cru ao recipe do make, que
o entrega ao /bin/sh (relato de time, 2026-08-24). Vale
igual no /contest/offline-submit e no
/problems/test-run. Teto de fonte
SUBMIT_MAX_KB (1024) → 413 source_too_large;
whitelist com CHÃO: lista de linguagens vazia = as da
PLATAFORMA (PLATFORM_LANGS, as 17 de
mojtools/lang/), nunca "qualquer extensão"
(.exe → 400 lang_not_allowed); e o submit é
fail-closed: o spool é validado ANTES do OK (falha →
500 spool_write_failed, sem linha pendente no history) — as
três correções do incidente 2026-08-19. Registra o editor em
var/editor-log p/ o card "editor da semana". Gate
por fase+papel (forçado pela API):
.admin/.judge submetem sempre;
.staff nunca
(403 submit_forbidden); usuário normal e
.mon só durante a janela
(403 contest_not_started antes do início,
403 contest_ended após o fim) — o .mon submete
mas fica fora do placar. Whitelist de
linguagens FORÇADA (400 lang_not_allowed): a
extensão do filename (canonicalizada: py3→py, cc/cxx→cpp…)
tem de estar na lista efetiva do problema — override do contest
(problem-langs.json) → LANGUAGES do conf →
languages do pacote → todas (fonte única
lib/langs.sh, a MESMA da listagem
/contest/problems). Campo opcional
virtual:"<cid>" (só com
contest=treino; docs/VIRTUAL.md): etiqueta a
submissão como parte da participação virtual do login — mesmo portão das
rotas do virtual (404 virtual_unavailable), problema tem de
ser da prova (400 virtual_problem), run tem de estar
rodando (403 virtual_not_running) e a whitelist de
linguagem passa a ser a do CONTEST. No TREINO o problema tem de
ser VISÍVEL ao login (404 problem_notfound):
público no índice (var/jsons/), ou privado de que ele é
dono/colaborador/membro da org; privado alheio e id inexistente
respondem IGUAL (a resposta não confirma existência) e nada entra na
fila; índice de donos indisponível = recusa (fail-closed). Antes, um id
privado conhecido era julgado p/ qualquer conta (2026-09-18). Em contest
o conjunto é o do conf — nada muda. |
/submission/source?contest=<c>&id=<subid> |
GET | Bearer | código-fonte (texto). Só o DONO da submissão e
juiz/admin (403 source_forbidden). O mesmo corte
vale p/ /submission/log (403 log_forbidden) e
/submission/summary (id alheio é omitido). A opção
SHOWCODE/show_code, que abria fonte, report e
resumo de TODOS a qualquer login do contest, foi removida em
2026-09-18: linha SHOWCODE em conf antigo é
morta |
/submission/log?contest=<c>&id=<subid> |
GET | Bearer | log do julgamento (report.html; expõe
input+diff de TODOS os testes). Em repouso é
mojlog/<id>.html.gz (2026-09-16): com
Accept-Encoding: gzip sai com
Content-Encoding: gzip, senão descomprimido; report apagado
pela retenção com results/<id>.json presente = nota
bilíngue "removido pela política de retenção". Juiz/admin sempre; dono
conforme o SHOWLOG efetivo
(showlog_effective em lib/verdict.sh):
SHOWLOG explícito no conf manda; ausente = OCULTO
em modo icpc (anti-vazamento de prova) e visível
nos demais modos |
/submission/summary?contest=<c>&ids=<csv> |
GET | Bearer | resumo ESTRUTURADO em lote (p/ a linha de detalhe sob o veredicto
canônico), de results/<id>.json:
{ "<id>":{verdict,verdict_canon,score,score_max,score_kind,correct,total,groups,heur_score?,heur_adjusted?} }
(veredicto manual com texto p/ o time: nos níveis
score/none verdict = o TEXTO do
time e verdict_canon = a classe; o daemon grava
verdict_team no results). REDIGIDO pelo modo do
contest (lib/verdict.sh): full
(treino/lista) = tudo; score (obi/heurístico/outro) =
canônico + score/groups/heur sem
correct/total; none
(icpc/ausente) = só o canônico (anti-leak: nem o dono
recebe score) — nos níveis redigidos verdict = canônico.
Juiz/admin: sempre full com verdict cru.
Mesmo gate do log (dono/admin/juiz; respeita o
SHOWLOG efetivo — explícito manda, ausente = oculto em
modo icpc); ids de terceiros são omitidos
(não 403). score_kind ∈ tests|points;
groups = [{earned,max},…] na ordem do
tests/score (earned null = grupo
não executado). Até 1000 ids; results antigos:
verdict_canon derivado da string e groups da
cauda legada Pontos | … | (com max:null) |
| Rota | Auth | I/O |
|---|---|---|
/contest/basic?contest=<c> |
— | {contest_id,contest_name,start_time,end_time,login_start_time,locale,login_enabled,freeze_time,score_anon,languages[],secret,modules[]}
(modules = ids dos MÓDULOS ligados do
contest — CONTEST_MODULES do conf, catálogo em
lib/modules.sh; é UX: decide quais grupos/painéis o admin e
o chefe mostram, o acesso continua cortado em cada rota;
languages = whitelist do conf LANGUAGES=;
[] = todas; locale =
pt/en explícito impõe o
idioma da interface do contest, "" = não setado ⇒ o front
cai no seletor/idioma do browser; round =
{slug,name,kind,warmup} da rodada ATIVA ou
null — é o que faz o front avisar em faixa fixa que aquilo
é AQUECIMENTO; cohort =
{id,name,unranked,public,view,released,views[]} da coorte
do login (só com sessão; null sem coortes) — o front avisa
o convidado e mostra o seletor Oficial × Geral;
score_views = [{id,name}] das
coortes PÚBLICAS com placar próprio (ranking:true — ex.:
times × individual), lista pública que vira o seletor do placar).
Continua público mesmo em contest secreto — a tela de
login/countdown precisa do nome p/ quem tem o link. Com Bearer de sessão
deste contest (opcional), end_time é o
fim EFETIVO do login (prorrogação por sede/grupo via
time-overrides.json — o countdown mostra o certo) Inclui
balloon_style
(icon|fill, default icon) = como
o placar pinta a célula resolvida — icon: fundo neutro +
ponto da cor (a cor deixa de ser o único sinal de "resolvido"; o balão
BRANCO da paleta padrão dava 1,00:1 contra o fundo e sumia) ·
fill: cor do balão no fundo + contorno derivado. Vale p/
placar, cerimônia e relatório; ver SCOREBOARD.md. Inclui
penalty_minutes (regra de pontuação, não
segredo — a cerimônia de revelação precisa dela p/ recalcular penalidade
e ORDEM; antes só existia no /contest/admin/settings,
admin-only, e o telão caía no default 20). Resposta em CACHE por
VARIANTE (`var/basic-cache.<anon |
/contest/userinfo?contest=<c> |
Bearer | {login,name, …team/país/univ/show_log opcionais} |
/contest/navbuttons?contest=<c> |
Bearer | botões por papel (SEM emoji desde 2026-09-15, issue #28; o
competidor ganha Minhas submissões →
/contest/submissions/, issue #26;
.admin/.judge/.staff/.cstaff
— o .cstaff ganha Etiquetas e, quando o
contest terminou p/ todas as sedes,
🏆 Revelação; o .staff não
tem mais Etiquetas; .staff/.cstaff ganham
📄 Documentos) Resposta em CACHE por PAPEL
(`var/nav-cache.<animeitor |
/contest/problems?contest=<c> |
Bearer | {problems:[{short_name,full_name,problem_id,has_statement_html,has_statement_pdf,statement_langs,time_limits,languages,author?}], statement_langs, default_statement_lang}
(statement_langs do envelope = os idiomas
que a prova OFERECE (STATEMENT_LANGS do conf, admin/chefe
em /contest/admin/statement-langs; ausente =
AUTOMÁTICO: todo idioma que cada problema tem, e o
envelope lista a união do que existe, pt sempre);
default_statement_lang = o LOCALE do
contest se oferecido, senão o 1º — é onde a sanfona abre;
statement_langs de cada problema =
oferecidos ∩ com arquivo
enunciados/<skey>[.<lang>].html|pdf ou tradução
no banco, materializada aqui na 1ª vez como o PT — a sanfona mostra
chips só quando >1) (author = crédito
de quem escreveu, do arquivo author do pacote; só sai
depois do fim para todas as sedes — ou p/
admin/juiz-chefe/juiz —, porque durante a prova o nome do autor é pista)
(problem_id = forma canônica coleção#problema,
igual ao treino — é o que o juiz usa p/ achar o pacote;
time_limits = {lang:seg} do store,
{} se o conf ocultar via SHOWTL=0 — com
pool de juízes definido (override do problema em
problem-judges.json → CONTEST_JUDGES do conf)
o MAX é só entre os hosts do pool efetivo;
languages = ids permitidos do problema: override por
problema (problem-langs.json) → whitelist do contest
(LANGUAGES) → default do próprio pacote
(.moj-meta.json languages, servido no índice
do treino) → [] (=todas) — o último elo faz um problema
"só-pddl" restringir sozinho sem o contest configurar nada; com
restrição, o front mostra um chip de TL por linguagem
permitida — o TL medido dela ou o default — e
omite o chip "padrão"; sem restrição, chips medidos + "padrão").
Gate de visibilidade (forçado pela API):
.admin/.judge veem sempre; .staff
nunca; usuário normal só após o início
— antes disso retorna {problems:[], locked:"not_started"}
(.staff → locked:"staff"), e o front mostra a
tela de contagem regressiva. Resposta em CACHE
(`var/problems-cache.<author |
/contest/samples?contest=<c>&problem=<letra|problem_id> |
Bearer | os exemplos do enunciado como dado `{problem,problem_id,samples:[{name,input,output} |
/contest/statement?contest=<c>&problem=<letra|problem_id>&format=html|pdf&lang=pt|en|es |
Bearer | UM enunciado, cru (text/html ou
application/pdf; format default
html). lang (default =
default_statement_lang do contest): fora da allowlist → 400
lang_invalid; idioma que a prova NÃO oferece →
404 (como problema inexistente); oferecido sem arquivo
→ serve o PT (tradução ausente nunca é erro). Arquivo =
enunciados/<skey>.<lang>.<fmt> ›
<skey>.<fmt>; ETag pelo arquivo resolvido;
X-MOJ-Statement-Lang = idioma do arquivo
servido. Gate IDÊNTICO ao da lista (can_see_problems):
.staff/.cstaff nunca, competidor só depois do
início, admin/juiz sempre — e a recusa é 404, não 403
(pedir o enunciado direto não pode confirmar que o problema existe antes
de a prova abrir). A chave do arquivo sai sempre do PROBS
do conf, nunca do parâmetro (problem=../x = 404). Responde
ETag (mtime+tamanho) +
Cache-Control: private, max-age=60 e honra
If-None-Match com 304 — recarregar a
página não repuxa MB, e enunciado corrigido no meio da prova invalida
sozinho. |
/contest/news · /contest/resources |
Bearer | seções opcionais (vazias = ocultar). Notícia pode ter anexo
{file:{name,size}} |
/contest/news-file?contest=<c>&id=<news_id> |
GET | Bearer |
/contest/backup?contest=<c> |
GET/POST | Bearer |
/contest/backup-file?contest=<c>&id=<id>[&login=<l>] |
GET | Bearer |
/contest/print?contest=<c> |
GET/POST | Bearer |
/contest/print-file?contest=<c>&id=<id> |
GET | Bearer |
/contest/staff/queue?contest=<c> |
GET | Bearer (.staff/.cstaff/admin) |
/contest/staff/print-action?contest=<c> |
POST | Bearer (.staff/admin; .cstaff
não — 403) |
/contest/staff/print-pdf?contest=<c>&id=<id> |
GET | Bearer (.staff/admin; .cstaff
não — 403) |
/contest/badges?contest=<c>[&staff=<l>&include_disabled=1] |
GET | Bearer (.cstaff/admin; .staff →
403 cstaff_required) |
/contest/doc?contest=<c>[&type=<info-sheet|contest|times>&lang=<pt|en|es>&fmt=<pdf\ **Tipos**: info-sheet|contest|times|editorial. **Gate de FASE** (além do de publicação): p/ quem NÃO julga (organização = só admin/chief/judge; **.staff/.cstaff/.monesperam a fase como o time** — decisão de 2026-09-15: a sede não recebe o caderno antes da prova),contest/times publicados só aparecem/baixam **a partir do início** (contest_phase
!=
before) e editorial só **depois do fim p/ TODAS as sedes** (contest_over_for_all— prorrogação segura);info-sheet`
= publicado é visível. A LISTAGEM filtra igual (o time nem vê a
linha). |
html>]` | GET |
/contest/rounds?contest=<c> |
GET | Bearer |
/contest/round?contest=<c>&round=<slug>[&file=index.html] |
GET | Bearer |
/contest/updates?contest=<c>&news_since=&clar_since= |
Bearer | resumo leve p/ polling de notificações:
{news:{last,count,unread}, clar:{last,count,unread}} (clar
= respondidas visíveis ao usuário; unread =
date/answered_at > since) |
/contest/history?contest=<c> |
Bearer | TXT (submissões do usuário). O veredicto (campo 5) sai
SEMPRE canônico
(Accepted/Wrong Answer/… —
lib/verdict.sh; veredicto manual com texto p/ o time sai
como o TEXTO — canon_team), em todos os
modos: a string de display com score/grupos fica no disco e o
detalhe por modo vem do /submission/summary (redigido).
Pendentes e strings desconhecidas passam intactos; o
sufixo (Ignored) é preservado. O history em disco não
muda |
/contest/balloons?contest=<c> |
Bearer | mapa letra/short→cor (default ICPC A–O) Resposta em
CACHE (var/balloons-cache.json, sem variante — o
mapa é o mesmo p/ todos). Sem teto de idade: as entradas cobrem 100% do
corpo (balloons.json + a paleta padrão, que é código — o
próprio handler entra como entrada, então um deploy invalida). |
/contest/regions?contest=<c> |
Bearer | regiões p/ filtro do placar (o filtro casa por nome
— igualdade com a sede .team.region do time via
/contest/teams — ou pelo
regex no login) |
/contest/teams-meta?contest=<c> |
— | regras regex→{country,school,school_full} {rules:[…]} —
placar resolve bandeira/escola e filtra por país/escola (bandeiras
locais em /shared/flags/). Fallback: só
preenche o que o por-usuário (/contest/teams) não
trouxe |
/contest/teams?contest=<c> |
— (secreto exige sessão) | ⚠️ com coortes, só os logins das coortes que o
chamador pode ver (é o endpoint PÚBLICO que mais vazaria um convidado).
diretório de TIMES por-usuário p/ o placar mesclar:
{teams:{<login>:{univ_short?,univ_full?,flag?,region?,has_logo,has_photo}}} (o NOME vem do TXT do placar — fullname)
— do .team do account.json + presença de
logo.png/photo.png; só logins
não-privilegiados com algo a dizer. Precedência no placar: isto
> teams-meta (regex) > vazio |
/contest/team-photo?contest=<c>&user=<l>[&thumb=1] |
— | foto do time (thumb=1 = miniatura de
320px, ~7 KB, com cache longo — é o que a galeria do
.animeitor usa). ⚠ Time sem foto NÃO dá mais
404: devolve a foto padrão do contest (200)
com o cabeçalho X-MOJ-Photo: placeholder — é o que faz o
Animeitor achar imagem para todo time. Quem precisa saber quem MANDOU
foto usa o has_photo das listagens (lado máx 1000) — o
placar não mostra isso (2026-08-24: "deixar simples");
quem cobra quem não mandou é a galeria do telão e o painel Pessoas ›
Times. Serve image/webp (formato de hoje) ou
image/png (acervo antigo — ver
lib/team-photo.sh). 404 só quando nem a padrão existe.
Pública, inclusive em contest SUPER SECRETO
(2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um
sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia
pouco e atrapalhava muito (nem o <img> do próprio MOJ
funcionava: tag de mídia não manda Authorization). O
SECRET continua trancando o que é dado de prova:
score, teams, teams-meta,
balloons e regions. |
/contest/team-music?contest=<c>&user=<l> |
— | música do time (audio/mpeg +
Content-Length): a faixa que o telão toca quando ele
resolve. Mesma doutrina da foto — time sem música NÃO dá
404: devolve a música padrão com
X-MOJ-Music: placeholder; quem precisa saber quem MANDOU
usa o has_music das listagens. Guardada como veio (mp3
validado por MIME, sem conversão — não há ffmpeg na
imagem). Sem Range: o player toca progressivo.
Pública, inclusive em contest SUPER SECRETO
(2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um
sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia
pouco e atrapalhava muito (nem o <img> do próprio MOJ
funcionava: tag de mídia não manda Authorization). O
SECRET continua trancando o que é dado de prova:
score, teams, teams-meta,
balloons e regions. |
/contest/placeholder?contest=<c>[&kind=photo|music][&thumb=1] |
— | o padrão do contest — o que a API responde no lugar
do asset de quem não mandou o seu: kind=photo (default) = a
foto, kind=music = a música. Escolhido pelo
.animeitor; sem escolha, o de fábrica
(server/etc/team-placeholder.webp / .mp3).
Pública, inclusive em contest SUPER SECRETO
(2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um
sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia
pouco e atrapalhava muito (nem o <img> do próprio MOJ
funcionava: tag de mídia não manda Authorization). O
SECRET continua trancando o que é dado de prova:
score, teams, teams-meta,
balloons e regions. |
/contest/team-logo?contest=<c>&user=<l> |
— | PNG do brasão do time (máx 128; célula do time no
placar — vence o logo por regra do teams-meta). 404 sem brasão.
Pública, inclusive em contest SUPER SECRETO
(2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um
sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia
pouco e atrapalhava muito (nem o <img> do próprio MOJ
funcionava: tag de mídia não manda Authorization). O
SECRET continua trancando o que é dado de prova:
score, teams, teams-meta,
balloons e regions. |
/contest/webcast?contest=<c>&key=<K> |
— (só a chave) | ZIP do placar no protocolo do Animeitor (o mesmo do
webcast.php do BOCA:
contest/runs/time/version/icpc,
campos separados por 0x1C). Rota SEM
SESSÃO, de propósito: é o sistema Animeitor buscando em loop. A
chave (criada pelo .animeitor) declara a visão de
coorte servida; chave inválida/revogada → 404
(e linha em var/webcast-denied.log). O pacote vai SEMPRE
descongelado — quem anima a virada é o Animeitor, que sabe a hora do
freeze pelo lastmilescore. Cache com piso de 10 s.
Formato inteiro em docs/WEBCAST.md |
/contest/score?contest=<c> |
(Bearer opcional) | TXT (1ª linha = modo, que pode trazer flags:
icpc s = célula resolvida em SEGUNDOS desde o início,
exibida pelos clientes como floor(seg/60); sem a flag =
minutos, legado) — ver SCOREBOARD.md.
Pré-início (regra: o placar nunca revela a quantidade
de problemas antes de a competição começar): antes do
CONTEST_START, quem não é is_judge recebe a
vitrine (var/placar-prestart.txt — os
times da visão pública com bandeira/sigla/nome e zero colunas de
problema; build.sh <c> --prestart). Cache
preguiçoso: (re)gera placar.txt (público, com
freeze) e placar-full.txt (completo, sem
freeze) se history/conf mudou.
Privilegiados
(.admin/.judge/.cjudge +
allowlist SCORE_FULL_USERS — vale p/ liberar um
.cstaff) com token recebem o completo; demais, o público.
&view=<visão> escolhe a visão de
coorte: public força a
pública/congelada mesmo p/ privilegiado,
oficial = só as coortes públicas, geral = tudo
(só vale p/ quem já pode ver tudo) e o id de uma coorte pública
com ranking (ex.: individual,
times) devolve o placar paralelo dela — é
público, não exige sessão, e a página
/contest/score/?c=<c>&view=<id> abre direto
nele. view=public — é o que a cerimônia de
revelação (/contest/score/reveal.html, estilo ICPC
resolver, nativa) usa p/ computar o delta frozen→full e revelar de baixo
p/ cima; o botão "Descongelar tudo" da cerimônia = settings
freeze:0 (só admin). &scope=mine (honrado
só p/ .cstaff) recorta o TXT servido
(frozen e full) aos usuários que o chefe de sede
enxerga (staff-filters) — é a cerimônia por
sede; fora da allowlist, o full só sai p/ o
.cstaff quando o contest terminou para todas as
sedes (contest_over_for_all: fim do conf + o
maior end de
time-overrides.json — sede prorrogada segura a revelação).
Em contest SUPER SECRETO (conf SECRET=1) o
placar deixa de ser público: sem sessão daquele contest
→ 401 secret_login_required (idem
balloons/regions/teams-meta). |
.animeitor, o admin do contest — e a
SEDE, recortada)A sede entra recortada pelo
staff-filters.json(o mesmo da fila/etiquetas/cerimônia): a listagem vem só com os times dela (scoped:true). O.cstaffusaphotos,photo,music,photos-zipe o GET deplaceholder— escrever em time de fora dá 403staff_scope. O.staffé somente leitura: sóphotose o GET deplaceholder(photo/music/photos-zip→ 403). Nenhum dos dois troca o padrão (POST 403) nem vê as chaves do webcast (403). | Rota | Método | I/O | |---|---|---| |/contest/animeitor/photos?contest=<c>| GET | galeria:{teams:[{login,name,univ,cohort,region,flag,has_photo,format,bytes,mtime,has_music,music_bytes,music_mtime}], total, with_photo, with_music, scoped, placeholder:{custom,mtime,music_custom,music_mtime}}(conta de papel fora). UMA varredura (find -printf+find\|xargs jq) para foto e música — com 1000 times são 0,1 s; umjqpor conta levava 5,3 s. Para a sede (.cstaff/.staff) a lista vem recortada nela (scoped:true; +0,05 s da 2ª varredura dostaff_visible_logins) | |/contest/animeitor/photo?contest=<c>| POST |{login\|filename, file_b64}sobe/troca a foto (convertida p/ webp, máx ~8 MB) ·{action:"delete", login}remove.loginaceita NOME DE ARQUIVO (fulano.jpg→fulano), que é como o envio em lote funciona. Auditado (animeitor-photo); toca.score-dirty..cstaffsó na própria sede (403staff_scope). ⚠ diferente doadmin/team-assets, não recusa contest comUSERS_FROM(a foto é asset local) | |/contest/animeitor/music?contest=<c>| POST |{login\|filename, file_b64}sobe/troca a música do time ·{action:"delete", login}remove. MP3 validado pelo MIME (file --mime-type=audio/mpeg; extensão não basta) → 400music_bad; máx 15 MB (413file_large). Corpo lido em ARQUIVO (read_body_file)..cstaffsó na própria sede (403staff_scope).loginaceita NOME DE ARQUIVO (fulano.mp3→fulano), que é como o envio em lote funciona. Auditado (animeitor-music) | |/contest/animeitor/photos-zip?contest=<c>| GET | ZIP do telão:fotos/<login>.webppara todos os times (quem não mandou foto leva a padrão) +musicas/<login>.mp3só de quem mandou +placeholder.webpeplaceholder.mp3na raiz +teams.csv(login,nome,universidade,coorte,bandeira,foto,padrao,musica,musica_padrao—padrao/musica_padraotrue= está com o padrão). A música padrão não é copiada por time: 5 MB × 1000 times viraria um pacote de gigabytes. Para o.cstaffo pacote sai recortado na sede dele | |/contest/animeitor/placeholder?contest=<c>| GET/POST | o padrão do contest: GET →{custom,bytes,mtime, music:{custom,bytes,mtime}}(o topo é a FOTO — contrato antigo); POST{file_b64}troca a foto (webp 1000px + miniatura),{kind:"music", file_b64}troca a música (mp3, máx 15 MB); POST{action:"reset"[,kind]}volta à de fábrica.kindfora dephoto\|music→ 422kind_invalid. Auditado (animeitor-placeholder). A sede (.cstaff/.staff) faz só o GET — o padrão é do contest inteiro (POST → 403) | |/contest/animeitor/webcast?contest=<c>| GET |{keys:[{id,key,view,label,created_by,created_at,revoked_at,fetches,last_at,last_ip}], views:[{id,name}], url_path, contest}— a chave aparece em claro (é o que se copia p/ o Animeitor) | |/contest/animeitor/webcast?contest=<c>| POST |{action:"create", view, label?}→{key, view}·{action:"revoke", id}. Visão inexistente → 422view_invalid. Auditado (webcast-key) |
.admin daquele contest)| Rota | Método | I/O |
|---|---|---|
/contest/admin/config?contest=<c> |
GET | {name,mode,start,end,letters[],colors,regions,teams_meta,basic:{locale,login_start,login_enabled,freeze}} |
/contest/admin/config?contest=<c> |
POST | {colors?,regions?,teams_meta?,basic?} → grava
balloons.json (escritor único
cc_balloons_write; {} não mexe,
null
apaga)/regions.json/teams-meta.json + vars
basic no conf (vazio = reseta). basic.freeze:0
(descongelar) só a partir do fim geral + 1 min (409
freeze_locked, ver
/contest/admin/settings) |
/contest/admin/users?contest=<c> |
GET | {users:[{login,fullname,email,admin,disabled,disqualified}],shared}
(sem senha) |
/contest/admin/user-add?contest=<c> |
POST | {login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?}
→ adiciona/reseta, devolve a credencial. fullname é
o nome do time (campo único — usuário de contest É o time); os
campos de TIME mesclam no .team do account.json |
/contest/admin/users-bulk?contest=<c> |
POST | carga em lote
{users:[{login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?}], on_existing?:skip|update}
(default skip; ≤5000; senha vazia = gerada).
fullname é o nome do time (campo único);
os campos de TIME (opcionais) gravam o
.team{univ_short,univ_full,flag,region} — carga
única de credenciais+país+sede+universidade (a UI aceita CSV
com cabeçalho: login,senha,nome,pais,sede,univ,univ_nome,
ordem livre — time/equipe são aliases de
nome). update: senha vazia =
regenerada (semântica de reset em massa);
nome/email só sobrescrevem se vierem na linha (linha
parcial de enriquecimento — ex.: login+sede — não clobbera o nome do
time) e os campos de time mesclam; conta privilegiada
existente (.admin/.judge/.cjudge/.staff/.mon) nunca é
tocada (skip privileged); criar privilegiada nova é
permitido. →
{created:[{login,password,fullname,email}],updated:[…],skipped:[{login,reason:exists|privileged|invalid|duplicate}],counts}.
Auditado users-bulk |
/contest/admin/user-remove?contest=<c> |
POST | {login} → remove (mv p/ .removed-users/,
dados preservados; toca .score-dirty — o placar o esquece
sozinho; não pode remover a si mesmo) |
/contest/classification?contest=<c> |
GET | público (gate de secreto igual ao placar; sessão OPCIONAL) |
/contest/admin/classify?contest=<c> |
POST/GET | admin |
/contest/admin/virtual?contest=<c> |
GET/POST | admin |
/contest/admin/user-disqualify?contest=<c> |
POST | {login, undo?} → DESCLASSIFICA
(.disqualified=true no account.json): a conta continua
existindo/logando, mas some do placar (sc_users)
e da estatística (stats-gen pula o login por inteiro —
placar e estatística sempre contam a MESMA população).
undo:true reverte. Não mexe em senha/sessões
(desclassificar ≠ desabilitar). Auditado
user-disqualify |
Reusa os editores de
web/shared/contest-config/(os mesmos da criação). Bandeiras locais/offline em/shared/flags/(271 países + 27 estados); GIFs do Sonic em/shared/assets/sonic/.USERS_FROM=<contest>no conf faz o login cair nopasswdcompartilhado (ex.: treino), mantendo o.adminpróprio.
| Rota | Método | Papel | Ação |
|---|---|---|---|
/contest/allsubmissions?contest=<c> |
GET | admin/chief/judge/mon | TXT 9 campos
(tempo:username:problemid:lang:verdict:epoch:subid:fullname:univ).
Admin/chief = completo. .judge puro e .mon =
ANÔNIMO: campos 2 (username), 8 (fullname) e 9 (univ)
vazios, aridade mantida, linhas ordenadas por epoch (o corte é na API —
curl não descobre quem submeteu; anonimato é só desta rota) |
/contest/final-verdicts?contest=<c> |
GET/POST | GET=judge; POST=admin/chief | opções de veredicto manual com 3
campos (2026-09-14): label = o que o JUIZ escolhe
(≤80); verdict = CLASSE canônica, uma das
6 de lib/verdict.sh (Accepted,
Wrong Answer, Time Limit Exceeded,
Memory Limit Exceeded, Runtime Error,
Compilation Error) — é o que pontua/penaliza/colore (422
verdict_invalid fora das 6); team = texto que
o TIME vê (≤60, sem :/¦; vazio = a classe;
Accepted nunca leva team). GET →
{verdicts:[classes], classes:[as 6], options:[{label,verdict,team}]}
(arquivo antigo com verdict fora das 6 é lido como classe
Wrong Answer + team = a string antiga). O
history recebe classe¦team (rv_canon_verdict);
quem fala com o time usa canon_team/vteam,
quem pontua usa canon/vcanon. Default = as 6
(6-Contact staff = Wrong Answer + team). Auditado
(final-verdicts-set) |
/contest/auto-verdicts?contest=<c> |
GET/POST | GET=judge; POST=admin/chief | matriz de veredicto automático
{ "<cid>": { "<lang|*>": ["<verdict>"] } }
(problema × linguagem × veredicto). GET →
{matrix,problems,verdicts}; POST {matrix}
(cids validados; lang minúsculo ou *). Auditado
(auto-verdicts-set) |
/contest/review/list?contest=<c> |
GET | judge | fila de revisão manual
{manual, options, items:[{id,login(**admin/chief**; null p/ juiz comum — anonimato),problem_id,lang,computed_verdict,status,conflict,created_at,claimants:[{by,elapsed_s,expires_in_s}],votes_n,my_vote,votes(**admin/chief**; oculto p/ juiz comum — anti-anchoring)}], counts:{not_evaluated,being_evaluated,awaiting_second,conflicts}, my_active, quorum}
— quorum = nº de juízes que validam cada veredicto (conf
REVIEW_JUDGES, 1..5, default 2);
awaiting_second = com voto(s) mas abaixo do quórum |
/contest/review/claim?contest=<c> |
POST | judge | {id,action:claim|extend|giveup} — máx 2 avaliadores,
1 ativa por juiz
(409 already_evaluating/slots_full), TTL 5 min
(extend=+5). Rejeita quem já votou
(already_voted). Auditado
(review-claim/extend/giveup) |
/contest/review/vote?contest=<c> |
POST | judge | {id,label} — registra o voto
(permanente) e libera o juiz (sai dos
avaliadores → pode pegar outra); rejeita voto repetido
(already_voted). 2 iguais → libera ao
aluno (enfileira setverdict,
review-agree); 2 diferentes →
conflict (review-conflict) |
/contest/review/resolve?contest=<c> |
POST | admin/chief | {id,verdict} (label ou classe da lista) — o juiz-chefe
resolve o conflito; libera ao aluno com classe¦team quando
a opção tem texto. Auditado (review-resolve) |
/contest/review/conflicts?contest=<c> |
GET | admin/chief | sumário dos conflitos
{conflicts:[{id,login,problem_id,lang,sub_epoch,computed_verdict,votes:[{by,label,verdict}]}], n, options}
(lang/sub_epoch p/ abrir log +
código na resolução) — n alimenta o alerta
global de conflito (banner + bip) que segue o chief/admin em
qualquer página (shared/chief-alert.js,
disparado via auth.status) |
/contest/review/stats?contest=<c> |
GET | admin/chief | estatística por .judge (do
admin-audit.log)
{judges:[{judge,votes,avg_response_s,timed,agreements,conflicts}], total:{votes,avg_response_s}}
— nº de veredictos, tempo médio claim→voto,
concordâncias e conflitos; alimenta a aba Situação do
juiz-chefe |
/contest/set-verdict |
POST | admin ou juiz-chefe | {contest,problem_id,verdict,username} — override direto
(modo legado/auto-resposta); verdict = label OU classe da
lista configurada (rv_canon_verdict; string livre = 422
verdict_invalid desde 2026-09-14); consumido pelo
daemon (setverdict) e finalizado pelo escritor
único |
/contest/rejudge |
POST | admin/chief | {ids:[…]} — marca cada submissão como pendente e
RE-JULGA (o daemon reconstrói a fonte arquivada + metadados do
history) |
/admin/adduser |
POST | admin | {contest,login,fullname,email?,password?} (gera
senha) |
/admin/passwd |
POST | admin | {contest,login,newpass} |
/admin/contest/extend |
POST | admin | {contest,end_epoch} |
/admin/synctreino |
POST | admin | sincroniza treino |
/admin/rejudge |
POST | admin | {ids:[…]} ou {contest,problem} |
/ops/queue |
GET | admin | tamanho da fila por contest |
/ops/problemtl?problem=<p> |
GET | admin | time limits do problema |
/ops/updateproblemset |
POST | admin | {repo} |
/ops/alerts |
GET/POST | bot | POST {ack:[{id,ok,error}]} = o bot
confirma as entregas do poll anterior (2026-09-14): item
.json sai do outbox p/
run/alerts/inflight/<id>.json no claim e VOLTA ao
outbox se não houver ack em ALERT_INFLIGHT_TTL (600 s); o
ack do relatório de quartil é o que marca sent
(rel_ack; ok:false grava
last_auto.outcome="failed: …" e o quartil segue devido). O
.txt de incidente continua at-most-once. GET: avalia
incidentes (juiz offline+fila, fila grande, daemon caído, bot
fora do ar — bot_gone, que só enfileira a mensagem
na VOLTA) com histerese/cooldown e drena o outbox:
{items:[{id,text,chats:[<chat_id>…],loud,group}]} (no
máx. ALERT_CLAIM_MAX=30 por poll — o Telegram corta acima
de ~30 msg/s; o resto sai no poll seguinte). O bot só entrega (+ grupo,
exceto quando group:false = mensagem
dirigida a UMA pessoa; loud:true = com notificação). Efeito
colateral: toca run/alerts/bot.alive
(heartbeat do bot — vira o campo bot do
/index/status e a linha 🤖 do /status/) e roda
a varredura do convite de time pendente
(inv_sweep_all, stamp próprio a cada
INVITE_SWEEP_THROTTLE=300 s: manda o "último aviso" quando
falta ≤ REG_REMIND_LEAD p/ a inscrição fechar) e o
relatório de quartil (rel_sched_check,
stamp próprio a cada RELATORIO_SWEEP_THROTTLE=3600 s:
quartil do semestre vencido e não enviado ⇒ gera e enfileira o painel
só para o grupo via alert_group — item
{chats:[],group:true}, o único destino é o
ALERT_GROUP_CHAT do bot). Estado em
run/alerts/; sem cron (o poll do bot é o relógio) |
/ops/relatorio |
POST | bot | painel de submissões p/ o grupo dos professores
(comando /relatorio do mojinho). Body
{telegram_id, args:[…], chat_id?, chat_type?} (de onde o
comando veio; o bot manda desde 2026-09-14).
aqui (só com chat_type
group/supergroup; 422 not_group) grava chat_id
em relatorio.json — o envio automático passa a ir SÓ para
esse grupo (alert_dm com chats:[chat_id],
group:false); sem chat_id cai no
ALERT_GROUP_CHAT do bot (alert_group).
refazer <k> desmarca
sent[k..4] (o próximo sweep reenvia). status
mostra destino e last_auto {k,at,outcome}. Admin ANÔNIMO no
grupo (from.id = GroupAnonymousBot) recebe 403
anonymous_admin com a explicação. Trilha em
run/alerts/relatorio.log; o sweep do
/ops/alerts carimba o stamp DEPOIS do trabalho (falha =
retenta em 10 min). o gate é PELO telegram_id: só conta
.admin do treino com Telegram vinculado (o
mesmo conjunto que recebe alertas; 403 admin_required).
args: vazio = relatório do semestre
configurado [inicio, agora] (409
not_configured/not_started) ·
AAAA-MM-DD = override pontual
[data, agora] (400 bad_date) ·
config <ini> <fim> = grava o
semestre em contests/treino/var/relatorio.json (quartis
passam a ser enviados automaticamente pelo sweep acima; os JÁ vencidos
entram pré-marcados — sem spam retroativo; 422
bad_date/bad_range) ·
status = config + quartis + enviados +
próximo. Resposta {html} (Telegram HTML): top-10 de
contests por submissões no período (treino em linha própria,
privilegiados excluídos), usuários ativos, vs mesmo período do ano
anterior e YTD vs anterior. Gerador score/relatorio-gen.sh
(uma passada em todos os users/*/history), cache com TTL
600 s em var/relatorio-cache.json. Base
fria: a geração síncrona tem orçamento de
REL_SYNC_BUDGET=50 s (frio já mediu ~70 s no prod, quente
~3 s); estourou ⇒ termina em background e a resposta
vem {html:"⏳…", pending:true} — repetir o comando em ~1
min serve do cache. O sweep de quartil usa cache PRÓPRIO
(var/relatorio-cache-auto.json, janela de until fixo =
imutável, exact-match sem TTL) e simplesmente envia no sweep
seguinte |
As rotas
admin/*eops/*(excetoops/alertseops/relatorio, que usam bot-token) são consumidas pelo painel admin e pelo moj-cli. O mojinho-bot hoje é transporte fino: usa sótreino/signup/*,treino/recover-password,ops/alertseops/relatorio(todos bot-tokenmojb_…), +/index/status(público).
| Rota | Método | Auth | I/O |
|---|---|---|---|
/index/status |
GET | — | health:
{queue:{total_pending,spool_queued,band_queued,lists[]}, judge:{online,total,busy,healthy,cpus_online,gpus_online}, alert:{no_judges}, daemons:{judged}, bot:{alive,last_poll_age_s}|null}
(cache 20s) — base da página /status/.
gpus_online conta SÓ juízes com GPU de compute
comprovada (registro com vendor nvidia/amd, vindo de
nvidia-smi/rocm-smi; adaptador de
display/lspci não conta). daemons.judged =
processo local (pgrep) ou heartbeat fresco
em run/judged.alive (≤JUDGED_ALIVE_TTL, 120s)
— no deploy podman a API e o daemon estão em containers diferentes e o
pgrep nunca o veria. bot =
saúde do bot de alertas (mojinho): mtime de
run/alerts/bot.alive (tocado a cada poll do bot em
/ops/alerts); alive = último poll ≤180s;
null = instalação sem bot (não é
incidente). Quando o bot fica >5 min sem polar e volta,
alerts_evaluate (bot_gone) enfileira UMA DM
aos .admin com o período fora do ar — o carteiro não avisa
a própria morte, mas avisa a ressurreição |
Permissão: usuários .admin sempre podem; demais por
lista do admin OU threshold de problemas resolvidos no
treino (com denylist). O contest entra no ar
imediatamente. Problemas vêm do banco público
(bank_id), por ID
(source+problem_id, p/ não-públicos) e/ou com
enunciado custom (em cada item name — ou title
— é OPCIONAL: sem ele o nome do problema no contest é o título
do banco, nunca o id; contests criados antes de 2026-09-18 sem
name ganham o título na listagem
/contest/problems) — manualmente ou sorteados por
tag/dificuldade. Usuários: compartilhados do
treino (users_from=treino; login pela conta do
treino, via fallback de verify_password) ou
próprios (users[], senhas geradas se em
branco). O admin do contest é sempre criado (sufixo
.admin garantido). Problema PRIVADO (no
topo OU em modules.rodadas.rounds[].problems) que o criador
não pode ver ⇒ 404 problem_denied sem
listar ids (idem no duplicate, com o duplicador como
sujeito). O spec unificado é VALIDADO como os painéis: id de módulo
desconhecido, regex de coorte/prorrogação/gate que não compila (ou
>200 chars), rodada com fim ≤ início / freeze fora da janela /
kind fora de warmup|official|extra, visão de
telão inexistente ⇒ 422
modules_spec_invalid; seção com
on:false é ignorada (2026-09-15). Exige ao menos um
problema — sem isso, 422 no_problems; para
criar vazio e configurar depois mande
allow_empty:true (booleano estrito; na web é o botão "Criar
vazio", na CLI a flag moj contest create --empty).
Acrescentar problemas depois não tem restrição
(/contest/admin/problems, com o contest já no ar). | Rota |
Método | Auth | I/O | |---|---|---|---| |
/treino/contest-create/permission | GET | Bearer |
{can_create,is_admin,is_superadmin,reserved_id_prefixes:["icpc"],reason,solved_count,threshold,in_allow,in_deny,allowed_modes,login,name}.
is_superadmin = login está em
SUPERADMINS do conf do treino; só ele cria contest com id
icpc* (o create/duplicate
respondem 403 id_prefix_reserved aos
demais). | | /treino/contest-create/problems?q=&limit=
| GET | Bearer+criador | autocomplete dos problemas que
o criador pode usar: públicos + os privados a que tem
acesso (dono, colaborador ou membro da org)
{problems:[{id,title,tags,access:mine\|shared\|public,private}],mine,shared,total}.
Privados primeiro; statement vem de var/jsons-private/.
/create recusa problema privado sem acesso
(problem_denied) e auto-valida (enfileira
index) os privados sem enunciado pronto — o contest mostra
o enunciado assim que o juiz indexa (contest/problems faz
fallback p/ jsons-private e cacheia) | |
/treino/contest-create/tags | GET | Bearer+criador | tags
do banco com contagem {tags:[{tag,count}],total} | |
/treino/contest-create/collections | GET | Bearer+criador |
coleções do banco público com contagem
{collections:[{collection,count}],total} (escopo ≠
/problems/collections, que conta sobre os problemas do
login) | |
/treino/contest-create/draw?tags=&collections=&count=&match=any\|all&difficulty=any\|easy\|medium\|hard\|known&seed=
| GET | Bearer+criador | sorteia problemas por tag,
coleção e dificuldade (filtros em AND;
dificuldade = taxa POR USUÁRIO de
lib/difficulty.sh: easy = muito fácil+fácil
(≥70 % de quem tenta resolve), medium = 50–70 %,
hard <50 %, unknown sem tentantes — cada
item traz difficulty, user_rate,
attempters, bucket), reproduzível por seed
{problems[],candidates,drawn,seed,collections}.
collections = array JSON url-encoded (nome
de coleção é texto livre — pode ter vírgula/espaço); casa
exato; inválido/ausente = sem filtro | |
/treino/contest-create/genpass?n= | GET | Bearer+criador |
N senhas legíveis (palavras-para-senha) {passwords[]} | |
/treino/contest-create/create | POST | Bearer+criador |
{id?,name,mode,priority?,start?,end,languages?,allow_empty?, admin:{login?,password?,fullname?}, (users_from? \| users:[{login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?}]), problems:[…], **modules?:{** (**mode** = icpc(default) \|obi\|treino\|heuristic\|outro(só.admin, 403 mode_forbidden); inválido = 422 mode_invalid; **não muda depois da criação** — exporte o spec, edite e recrie. SPEC UNIFICADO — um JSON levanta o contest inteiro: sedes{regions,teams_meta,time_overrides}, baloes{colors,during_freeze}, coortes{cohorts}, maquinas{ua_gate,site_lock{enabled,grace},nutella_url}, rodadas{active,rounds}, documentos{config}, inscricoes{enabled,window{open,close,late_minutes,team_max,teams,warmup_open}}, telao{views[{view,label}]}→ chaves NOVAS de webcast,classificacao{algorithm,config}→ stage em rascunho; seção presente = módulo ligado salvoon:false; tipo errado = 422 modules_spec_invalid; SEGREDO nunca entra), compat: colors?:{A:"RRGGBB",…,enableSonic?}, regions?:[…], teams_meta?:[{regex,country,school?,school_full?}]no topo continuam aceitos (ligambaloes/sedes), locale?,login_start?,login_enabled?,freeze?, show_log?,show_editor?,show_tl?,allow_backup?,allow_print?,score_anon?,manual_verdict?,allow_late?,secret?,login_ua_substring?,score_full_users?,penalty_minutes?,penalty_verdicts?}
→
{contest_id,admin_login,admin_reused,admin_password,users[],users_from,url,scoreboard_url}.
Paridade com o settings: os toggles/opções espelham
/contest/admin/settings (grava só o não-default).
languages aceita array de ids canônicos
(normaliza como o settings) ou string legada. priority =
prioridade no escalonador (prova/lista-privada/lista-publica;
super só admin — como mode:outro).
judges[] = pool de juízes do contest (→
CONTEST_JUDGES; entra também no template/export/duplicate).
Por problema: languages[] (vira
problem-langs.json), judges[] (vira
problem-judges.json) e
statement_pdf_b64/statement_pdf_file (além do
HTML). Admin não é sobrescrito: senha digitada é
respeitada; em modo compartilhado, se o <login>.admin
já existe na fonte users_from ele é
reutilizado — admin_reused:true,
admin_password:null | |
/treino/contest-create/template | GET | Bearer+criador |
baixa template JSON completo (documenta todos os campos
do create, incl. toggles/priority/users/visual) | |
/treino/contest-create/import | POST | Bearer+criador |
{tar_b64} (.tar.gz com contest.json +
enunciados/) → cria | |
/treino/contest-create/templates | GET/POST |
Bearer+criador | templates nomeados por criador
(treino/var/contest-templates/<login>.json). GET
lista os meus (?name= → 1, senão 404). POST
{op:save,name,(template{}\|from_contest,include_problems?)}
| {op:delete,name} |
{op:rename,name,new_name}. O spec salvo é
relativizado + whitelist no servidor: datas viram
duration/login_lead/freeze_before_end;
nunca guarda usuários/senhas/id/datas absolutas; a
seção modules{} entra sem o que é preso a data
(rounds, active, time_overrides).
from_contest: só dono do contest ou admin (senão
404). Limites: 20/usuário, spec ≤64KB | |
/treino/contest-create/export?id=&full_statements=0\|1
| GET | Bearer+criador | baixa o spec JSON de um
contest existente (formato do /create — round-trip). Traz a
seção modules{} dos módulos LIGADOS com os
dados reeditáveis (regions, cores, coortes, gate de UA, plano de rodadas
sem as arquivadas, config de documentos sem published,
janela de inscrição, views do webcast SEM chave, algoritmo/config da
classificação) — nada de regions/colors/teams_meta no topo.
Gate: created-by + (dono ou admin) — senão
404. Nunca exporta
passwd/users/senhas/submissões. Enunciados: default embute só o material
exclusivo do contest (sem json público no banco);
full_statements=1 embute tudo | |
/treino/contest-create/duplicate | POST | Bearer+criador |
{from, id?, name?, start?, end?, admin?, users?\|users_from?}
→ cria contest novo copiando conf+problemas+módulos do
from (usuários/submissões nunca; enunciado
custom copiado por arquivo; o PLANO de rodadas anda junto com as datas —
mesmo delta —, prorrogações por sede não viajam, o webcast ganha chaves
novas). Datas: start=agora, end=start+duração
original; login_start/freeze relativos
preservados; name default "Cópia de …". Gate do
from = o do export (404) | |
/treino/contest-create/mine | GET | Bearer+criador |
contests criados por mim (owner==login +
created-by)
{contests:[{id,name,mode,created_at,start,end,problems_count}],total}
(admin usa /treino/admin/contests p/ a lista completa) | |
/treino/admin/contest-perms | GET/POST | admin | GET
{perms:{threshold,allow[],deny[],allow_meta{},deny_meta{}}, allow_info:[{login,name,has_photo,by,by_name,at,note}], deny_info:[…], me}
— a trilha de quem liberou/bloqueou e quando
(2026-09-15). POST por ação:
{action:"add",list:"allow"\|"deny",login,note?} (conta tem
de existir no treino: 404 unknown_login;
*.admin na allow: 422
already_admin; sai da lista oposta; carimba
by/at),
{action:"remove",list,login},
{action:"threshold",threshold}. POST legado
{threshold,allow[],deny[]} (substituição total) segue
aceito e carimba meta nas entradas novas. Resposta = a do GET +
saved. | | /treino/admin/contests | GET |
admin | contests criados pela interface que este admin pode
ver (cc_contest_visible_to, 2026-09-15):
super-admin (SUPERADMINS no conf do
treino) vê todos (scope:"all"); .admin comum
vê os seus e os de criadores sem papel de admin
(scope:"admin") — nunca o contest de outro
.admin.
{contests:[{id,name,mode,owner,owner_name,owner_has_photo,owner_is_admin,created_at,start,end,problems_count}],count,scope,me,is_superadmin}
| | /treino/admin/contest-remove | POST | admin |
{contest} → move p/ lixeira (só os criados pela interface);
contest fora do escopo acima = 404 (não confirma a
existência). duplicate/export do criador
seguem o mesmo escopo. |
Ações auditadas (em
treino/var/admin-audit.log):contest-create,contest-template,contest-export,contest-perms,contest-remove— além denews-*,logout-*,lock-user.
O CLI moj-contest
(web/moj-contest, servido em GET /moj-contest;
fonte em moj-cli/; moj contest … delega a ele)
cobre estas rotas e as de /contest/admin/*: criação (spec/
template), templates nomeados, export/duplicate, settings, problemas
(com sorteio por coleção), usuários, sessões,
auditoria, remoção e os documentos da prova
(docs ls|gen|get|publish|unpublish|cover|set|text —
ls/get valem p/ QUALQUER conta do contest,
então a sede (.cstaff) baixa o publicado
pelo terminal, útil em rede isolada). Sessões: criação/reuso = token do
treino (moj login); administração = token
daquele contest
(moj-contest login <cid>, conta *.admin
do contest) — o corte de acesso é sempre o do servidor.
Acessado por <id>.moj.<base> (subdomínio): o
nginx injeta CONTEST_HOST; a API só serve aquele contest
(auth/contest/submit/submission)
e o frontend redireciona o resto para /contest/. ⚠ Esse
isolamento vale só para quem ENTRA pelo subdomínio — da máquina de
prova, curl --resolve moj…:443:<IP> chega ao site
base pelo mesmo IP; quem fecha isso é a trava de sede por
IP (/contest/admin/site-lock, 403
site_locked). Login com gate opcional por substring de
User-Agent (LOGIN_UA_SUBSTRING, só não-privilegiados).
Papéis: .admin/.judge/.cjudge
(juiz-chefe, herda juiz)/.staff/.mon.
| Rota | Método | Papel | I/O |
|---|---|---|---|
/contest/admin/sessions?contest=<c> |
GET | admin | sessões ativas
(sessions[]{login,name,ip,user_agent,login_at,mkey,multi_ip,multi_ua})
+ alerta de UA/IP diferentes. mkey = chave de máquina
(m:<machine_id>/<boot_id> do UA do mlinux,
senão ip:<ip> — lib/session-index.sh). A
varredura SEMEIA o índice de sessões por login quando ele ainda não
existe (run/sessions/.idx/<c>/) |
/contest/admin/access-log?contest=<c>&day= |
GET | admin | log de acessos (epoch/login/ip/UA) + alertas |
/contest/admin/site-lock?contest=<c> |
GET/POST | GET admin ou .cjudge; POST só admin |
trava de sede por IP
(lib/site-lock.sh; conf SITE_LOCK=1,
SITE_LOCK_GRACE s, default 3600). Com a trava, todo login
de COMPETIDOR reivindica o IP de origem p/ o contest até
CONTEST_END+grace (estado
run/site-lock/<ip>, uma linha por contest); o
router.sh responde 403
site_locked a qualquer pedido daquele IP a outro
alvo (treino, índice, /problems, outro contest — inclusive
sessão antiga), exceto conta de papel e auth/logout. É o
que fecha curl --resolve da máquina de prova ao site base.
Auditado: 1ª reivindicação de cada IP
(site-lock-claim ip= login= until=) e cada bloqueio
(site-lock-block ip= target= route= login=, teto 1/5 min
por ip+alvo; o contador blocked sobe sempre). GET →
{enabled, grace, claims:[{ip,until,first,last,logins,blocked,last_block,last_target,active}], blocks:[…do audit…], claims_audit:[…]}.
POST {action:"set", enabled, grace?} ·
{action:"release", ip} · {action:"claim-seen"}
(prende os IPs de competidor vistos na janela da rodada, aquecimento
incluso). Painel: Pessoas › Sessões & anomalias (tabela + bloqueios)
e a chave em Máquinas & gate › 🔒 |
/contest/admin/anomalies?contest=<c>[&round=<slug>] |
GET | admin ou .cjudge |
anomalias de uso de máquina DURANTE a prova
(lib/anomalies.sh; painel Pessoas › Sessões &
anomalias). SÓ vale com o gate de UA ligado (gate.active;
sem gate só counts.sessions e a trilha). →
{gate:{mode,single_session,active}, round, window:{start,end,since}, computed_at, session_classes:{competitors,staff,privileged}, counts:{sessions,teams_live,multi_session,machine_shared,sub_other_machine,reboot,ua_mismatch,site_short,switched,revoked,events}, anomalies:[{kind,severity:bad|warn|info,at,login,name,region,machine,detail}], events:[…sessão única/logout…], teams:[{login,name,region,sessions[],machines[],last_sub:{at,key,same_as_session,same_as_login_machine},flags[]}], machines:[{key,logins[],shared,live}], sites:[…sede com menos máquinas que times…], channels:{logins:{web,cli,other}, submissions:{web,cli,other,offline}}, nutella_at}
(channels vale mesmo sem gate: canal pelo UA — a CLI se
marca moj-<tool>/<build>, navegador começa por
Mozilla/; offline = pacote do
/contest/offline-submit). events[] inclui
kind:"site_lock" (reivindicações e bloqueios da trava de
sede lidos do audit;
counts.site_lock_blocks/site_lock_claims). Tipos:
multi_session (2 sessões vivas em máquinas diferentes),
machine_shared (2+ times na mesma chave na prova;
bad se ambos vivos), sub_other_machine
(requisição de chave ≠ da sessão; mesmo machine_id com boot
diferente = reboot, info), ua_mismatch,
site_short (cache do nutellaboot), switched,
session_event. Só a chave m: (UA do
mlinux) identifica máquina: login com chave ip:
(navegador comum; atrás de NAT o IP é a sede inteira) só entra em
ua_mismatch e nas sessões. Fontes:
var/access.log, var/submit-origin.log,
var/session-events.log, sessões vivas (índice),
ua-gate.json, var/nutella.cache.json. Cache de
resposta 15 s por rodada; identidade dos times
(var/.an-users.json) e esperado do gate
(var/.an-exp.json) em cache de 5 min; extrato do
nutellaboot (var/.an-sites.json) invalidado pelo próprio
cache; sessões lidas do índice com UM grep (sem source por
sessão). Com gate.active:false, teams[] vem
VAZIO (o painel esconde a tabela; eram 1,3 MB na LATAM).
session_classes conta TODAS as sessões vivas do contest por
classe (a caixa "sair em massa" lê daqui) |
/contest/admin/audit-log?contest=<c>&since=&action=&user=&limit= |
GET | admin | feed unificado (trace no instante exato de cada
evento) {events:[{time,who,kind,action,details}],count}. 4
fontes: admin (var/admin-audit.log),
login (var/access.log), submit (1
por submissão, no sub_epoch do
users/<login>/history), verdict (1 por
correção, no finalized_at do
users/<login>/results/<subid>.json — traz o
juiz; who = o aluno). Cada submissão gera 2
entradas: a submissão (quando o aluno enviou) e o veredicto
(quando o juiz respondeu); pendente = só a submissão. As 4 fontes viram
NDJSON num temporário e saem numa passada de jq --slurpfile
— nunca --argjson (o array do admin-audit
sozinho passa dos 128 KiB de MAX_ARG_STRLEN) |
/contest/admin/dashboard?contest=<c> |
GET | admin | situação ao vivo:
{judges:{online,busy,total,queue_depth,assigned,pool[],list[]}, routing, submissions:{total,pending,pending_list[],max_wait_s,response:{avg_s,max_s,p50_s,p95_s},timeline[]}}
(routing = shards do escritor, mesmo shape do
/treino/admin/queue) (janela = últimas N submissões;
pool = hostnames de CONTEST_JUDGES,
[] = sem pool — o front marca ⭐ os hosts do pool e alerta
pool offline) |
/contest/admin/settings?contest=<c> |
GET/POST | admin | (show_code foi REMOVIDO em 2026-09-18: o GET não o
devolve; o POST aceita e IGNORA a chave — cliente antigo manda o
formulário inteiro — e apaga a linha SHOWCODE do conf.) GET
traz também modules[] (ids ligados;
muda-se em /contest/admin/modules) e
statement_langs/statement_langs_mode
(auto|list)/default_statement_lang
(só leitura aqui; muda-se em
/contest/admin/statement-langs). Tempos, login on/off,
abertura, freeze (**freeze:0 = DESCONGELAR
só a partir de freeze_release_at =
contest_end_all + 60 s, prorrogações por sede incluídas —
409 freeze_locked com a hora na mensagem; vale p/ TODOS os
caminhos que zeram o freeze: este, config basic.freeze,
finish, promoção de rodada e rounds set na
ativa (freeze_change_guard, comparação NUMÉRICA —
"00" é zero); empurrar um freeze JÁ EM VIGOR para
depois de agora também é descongelar (409); mover o freeze
antes de ele entrar em vigor é livre; o GET traz
freeze_release_at p/ a UI mostrar a hora —
regra de 2026-09-14), locale, tz (fuso
IANA da prova → CONTEST_TZ; vazio/null volta ao
MOJ_TZ da instalação, 422 tz_invalid se não
existir no zoneinfo — governa TODA hora que o SERVIDOR escreve p/ gente
sobre o contest: DM do mojinho, preflight, caderno, relatório; a web
sempre mostrou no relógio do browser), toggles
show_log/show_editor/show_tl/allow_late/score_anon/allow_backup/allow_print/manual_verdict/secret,
login_ua_substring, languages[] (whitelist do
contest), judges[] (pool de juízes do
contest: hostnames do registro, vazio = qualquer juiz online; vira
CONTEST_JUDGES no conf — o job leva
allowed_hosts e o escalonador é ESTRITO:
pool offline segura a fila; o TL de /contest/problems passa
a ser só do pool), score_full_users[] (logins que veem o
placar completo além de
.admin/.judge/.cjudge).
Penalidade ICPC: penalty_minutes (int,
default 20) e penalty_verdicts (array de códigos
wa/tle/mle/rte/ce, default sem ce) — quais
verdicts contam penalidade e o peso por tentativa; Judge Error/pendentes
nunca contam; mudar freeze/penalidade dispara
rebuild FORÇADO (score_kick_rebuild — imune à
corrida de mtime com build em voo; ver
/contest/admin/finish). O GET devolve também
mode (read-only, modo do placar).
show_log é o valor EFETIVO: em modo
icpc com SHOWLOG ausente do conf o default é
false (o report expõe os testes — anti-vazamento); no
POST, show_log:true grava SHOWLOG=1
explícito (religar fica registrado) e
false grava SHOWLOG=0. secret =
SUPER SECRETO (fora das listagens públicas;
placar/visual exigem login no contest; a UI exige digitar o id p/
desmarcar). manual_verdict (opt-in, default OFF) liga o
veredicto manual: o daemon SEGURA o veredicto computado
p/ revisão de juízes humanos (exceto o que a matriz
auto-verdicts libera).
review_judges (int 1..5, default 2 =
ausente do conf; vira REVIEW_JUDGES) = QUANTOS juízes
validam cada veredicto — N votos unânimes liberam; divergência vira
conflito p/ o chief; 1 = revisão simples. Desligar
manual_verdict VARRE a fila de revisão: o que
ninguém contestou (sem voto e sem conflito) é liberado com o veredicto
COMPUTADO — senão as sobras ficavam presas p/ sempre (o
juiz comum não consegue mais votar e o competidor fica em Not
Answered Yet); item com voto ou em CONFLITO não é
atropelado e fica p/ o juiz-chefe. A resposta traz
review_released/review_pending; auditado
review-manual-off.
balloons_during_freeze (bool, default
false = retém) = entregar balão com o placar CONGELADO.
Default protege o freeze: AC feito no congelamento não vira tarefa de
entrega e não é entregue depois (ver /contest/staff/queue).
LIGAR libera retroativamente o que ficou retido (apaga
as lápides + o stamp; o próximo carregamento da fila materializa tudo —
id determinístico, não duplica) e a resposta traz
balloons_released; auditado
balloon-freeze-release. O GET traz também
balloons_frozen = quantos estão suprimidos
agora. balloon_style
(icon|fill, default icon =
SCORE_BALLOON_STYLE ausente do conf; 422
balloon_style_invalid) = como a célula "resolveu" é pintada
no placar/cerimônia/relatório (ver SCOREBOARD.md).
guest_numbering (bool,
GUEST_NUMBERING; issue #25) = convidados (coorte unranked)
numerados na sequência própria — a 1ª linha do TXT da visão com
convidados vira icpc s g; mudar dispara rebuild |
/contest/admin/seed?contest=<c> |
POST | admin do contest, e só com DEMO=1 no
conf (senão 403
demo_required) |
povoa um contest de DEMONSTRAÇÃO com times e
submissões SINTÉTICAS — existe para quem desenvolve o
Animeitor (ou qualquer cliente de placar) ter um placar
de verdade para trabalhar sem uma prova acontecendo. Body (tudo
opcional):
{teams:20, submissions:200, seed:1, freeze_minute, window_minutes, password:"demo1234", verdicts:{accepted,wrong,tle,rte,ce,pending}}.
Cria os times que faltarem (time-01…N, com
.team sigla/bandeira/sede) e escreve as submissões pelos
mesmos escritores do veredicto real
(user_history_append + metrics_recompute +
score/build.sh) — o resultado é indistinguível para placar,
estatística, webcast e balões. Determinístico pelo
seed (mesmo seed ⇒ mesmo placar; a janela é
arredondada a minuto cheio e window_minutes a fixa).
freeze_minute grava o FREEZE_TIME (é o que faz
placar.txt diferir de placar-full.txt). O
probid gravado é o canônico
(PROBS[i+4]) — qualquer outra grafia deixaria a célula em
branco no placar em silêncio. Limites: teams 1..500,
submissions 0..20000. É ADITIVO: chamar de
novo soma ao que já existe (times que já existem não são recriados) —
para começar do zero, apague o contest e crie outro. Resposta:
{teams, teams_created, submissions, seed, freeze_time, window_minutes, password, board_lines, runs_after_freeze, hint, by_verdict{}}
— runs_after_freeze:0 com freeze pedido vem com
hint: o congelamento caiu na borda da janela
semeada, o placar congelado sai igual ao completo e não há revelação
para testar. Auditado (seed). ⚠ a marca DEMO=1
só é gravada na CRIAÇÃO do contest (demo:true no spec do
/treino/contest-create/create) — não há toggle que a ligue
depois |
/contest/admin/problems?contest=<c> |
GET/POST | admin | GET inclui languages e judges por
problema; {action:add|remove|reorder|rename} (reescreve
PROBS) — rename
{letter, name?, new_letter?} também troca o
IDENTIFICADOR (^[A-Za-z0-9]{1,3}$; em uso = 422
letter_taken; a cor no balloons.json migra
junto) e reorder só re-letra pela posição quando as
letras atuais são a sequência automática A,B,C,… —
identificador customizado (W1…) sobrevive à reordenação,
{action:langs,letter,languages[]} (whitelist por problema
em problem-langs.json),
{action:judges,letter,judges[]} (pool de juízes por
problema em problem-judges.json; vazio = herda o
pool do contest) ou
{action:statement,letter, html_b64?|pdf_b64?|remove_html?|remove_pdf?|refresh?}
(enunciado por problema em
enunciados/<skey>.{html,pdf}; refresh
re-indexa do banco). add de problema
PRIVADO: só se o dono do contest (arquivo
owner) for dono/colaborador do problema (mesmo guard da
criação); senão 404 (não vaza a existência). Contest
sem owner (legado): só público |
/contest/admin/bank?contest=<c>&q=&limit=&collection= |
GET | admin | busca p/ adicionar problemas: banco público + os PRIVADOS a
que o dono do contest tem acesso (dono/colaborador no índice —
o mesmo sujeito do gate de add; a busca lista exatamente o
que pode entrar). Privados primeiro.
{problems:[{id,title,tags,collections,access:mine|shared|public,private,has_statement}],total,mine,shared}.
Contest sem owner (legado) → só públicos.
?meta=1 →
{tags:[{tag,count}],collections:[{collection,count}]}
(agregado do banco público — sorteio é público) |
/contest/admin/draw?contest=<c>&tags=&collections=&count=&match=&difficulty=&seed= |
GET | admin | sorteio no banco público (mesmo contrato do draw do wizard:
coleção/tag/dificuldade em AND, collections = array JSON
url-encoded, reproduzível por seed) |
/contest/statistics?contest=<c> |
GET | admin/judge/mon | totais, por-problema (first_minute
relativo ao início + first_seconds p/
desempate + first_solver_name = nome do
time de quem resolveu primeiro; estatística nunca mostra só o login, e o
nome é resolvido no CACHE porque os dois consumidores — painel e
relatório offline — não consultam contas), por-linguagem, veredictos,
linha do tempo. Tempo = sub_epoch - CONTEST_START (não
EPOCH). Só usuários normais (descarta
.admin/.judge/.staff/.mon). Recortes
prontos (2026-08-30): by_region:{<sede>: …}
e by_country:{<flag>: …} — cada valor tem o MESMO
shape do agregado global
(totals/problems/languages/verdicts/timeline/dists,
first_solver* recalculado DENTRO do recorte), computados na
mesma passada do gerador; sede = .team.region e
também cada NÓ da árvore de regions.json (país ›
região/supersede › sede — o nó agrega por REGEX de login, como o
regionMatch do placar, com dedup quando nome == sede; regex
inválida é descartada), então o seletor de Sede da estatística oferece a
MESMA árvore do placar; país = o PREFIXO do
.team.flag minúsculo (br-pr → br:
time brasileiro declara bandeira de ESTADO e "estatísticas do Brasil"
tem de juntá-los — o filtro "Bandeira" do placar casa pela mesma
hierarquia); conta sem o dado fica fora do recorte correspondente. A UI
(/contest/statistics/) expõe dois selects mutuamente
exclusivos. População (2026-08-31, relato da LATAM):
totals traz enrolled (INSCRITOS
não-privilegiados, a mesma população do placar), users
(quem submeteu) e absent (a diferença) — no global e em
cada recorte; o bucket 0 do problems_solved_dist INCLUI os
ausentes (a distribuição casa com o placar). Nó do
regions.json com view:true
(supersede/femininos — recorte que SOBREPÕE as sedes) sai com
view:true na fatia e a UI avisa "não some com as sedes" (⚠
fatia é chaveada por NOME: dê nomes próprios aos recortes).
Estatísticas 2.0 (2026-09-01): problems[]
ganha
avg_ac_min/tries_per_ac/dirt/difficulty
(rótulo pelo accept_rate por time, mesmas faixas do treino
— lib/difficulty.sh) (métrica do resolver ICPC: % de subs
erradas entre quem resolveu)/ac_langs; cada recorte tem
dirt; o GLOBAL ganha ac_events
([[login,prob,minuto,tentativas]…], 1º AC de cada
time×problema, convidados inclusos — base ÚNICA das seções
corrida/comparação/desempenho, que a UI filtra pelo recorte corrente),
teams_idx (login→{n:nome,c:país,r:sede} de
todo time com AC), penalty_minutes e
unranked_regex (a regex das coortes convidadas, p/ o
cliente aplicar o MESMO corte do ranking), top_teams (10,
oficiais) e performance (média/mediana/quartis/p90 de
resolvidos; média/mediana/quartis de penalidade ICPC;
first_ac_median) — a UI recomputa desempenho/top 15
client-side por recorte e só mostra o quadro com ≥30 times com AC. Cache
em var/statistics.cache.json
(server/score/stats-gen.sh), invalidado por
history/conf. |
/contest/clarifications?contest=<c> |
GET | Bearer | role-aware (admin/judge/mon = todas; demais = próprias + públicas,
sem answered_by). Quem perguntou
(login + asker_name) só o juiz-chefe/admin
recebe (2026-09-14, pedido do juiz-chefe);
.judge/.mon continuam SEM .login
(tratamento isonômico) e o relatório público segue anônimo. Privilegiado
recebe answer_claim (reserva expirada já vem
null). Envelope:
{clarifications, can_answer, can_edit, is_chief, me} —
can_edit = juiz-chefe OU admin (editam resposta dada,
liberam reserva alheia com force); is_chief é
compat e vale o mesmo |
/contest/clarification-ask?contest=<c> |
POST | Bearer | {problem?,question}. Gate de janela
(competitor_write_guard, a MESMA do /submit;
2026-09-15): time e .mon só durante a
prova — antes 403 contest_not_started, depois 403
contest_ended (fim EFETIVO da sessão: sede prorrogada segue
perguntando);
.staff/.cstaff/.animeitor nunca
(403 role_forbidden); admin/juiz/chefe sempre |
/contest/clarification-claim?contest=<c> |
POST | admin/judge/mon | {id,action:claim|release,force?} — reserva p/
responder (dois juízes não pegam a mesma; TTL
CLAR_TTL 5 min, expira na leitura). Ninguém reserva
por cima de outro (409 clar_claimed, juiz-chefe
incluso). release de reserva ALHEIA só com
force:true e juiz-chefe/admin (senão
409 clar_claimed); a resposta traz forced_from
e o audit clar-release … forced_from=<juiz>. Auditado
(clar-claim/clar-release) |
/contest/clarification-answer?contest=<c> |
POST | admin/judge/mon | {id,answer,public?} — sob flock + reserva;
já respondida só o juiz-chefe/admin edita
(409 already_answered); abertas exigem a reserva
(409 clar_claimed). Auditado (edited=) |
/contest/clarification-broadcast?contest=<c> |
POST | admin/judge/mon | aviso oficial
{problem?,question?,answer} — answer é o TEXTO
do aviso (obrigatório; 422 answer_missing);
question é o ASSUNTO, opcional (a UI o mostra como título).
Público, broadcast:true, autor oculto
(login:""; UI mostra "Organização"). Auditado |
/contest/admin/cohorts?contest=<c> |
GET/POST | Bearer (GET admin ou .cjudge; POST
só admin) |
coortes de placar (times oficiais ×
CONVIDADOS/extra-oficiais; motor em lib/cohorts.sh, formato
em docs/SCOREBOARD.md). GET →
{cohorts:[{id,name,regex,public,unranked,default,sees}], results_released, views, counts:{id:n}, by_regex_only:[login]}.
POST {action}:
add/set
{id,name?,regex?,public?,unranked?,sees?,default?} (regex
tem de COMPILAR; máx 8 coortes; a marca default é única e
sempre existe) · rm {id}
(recusa a default e coorte com time: 409
is_default/cohort_in_use) ·
assign {login,cohort} (grava
.team.cohort; "" devolve o time à regra) ·
materialize (carimba o campo em quem hoje
casa só por regex) · release
{on} = liberar os resultados (todos passam
a ver todos). Tudo auditado (cohorts-*) e toca
var/.score-dirty |
/contest/admin/statement-langs?contest=<c> |
GET/POST | admin ou .cjudge |
idiomas do enunciado que a sanfona oferece (conf
STATEMENT_LANGS). GET
{mode:auto|list, langs[], default, all:[pt,en,es], locale, available:{<letra>:{<lang>:true}}}
(available = idioma com arquivo no contest ou tradução no
banco). POST {mode:"auto"} = automático (o
default; apaga a var — todo idioma que cada problema tem entra na
sanfona) ou {langs:["pt","en"]} = lista fixa (allowlist
pt/en/es, 422 lang_invalid; vazia ou só pt =
prova só em PT, gravado STATEMENT_LANGS=pt; 400 sem
mode/langs). Os dois materializam do banco as
traduções que faltam
(enunciados/<skey>.<lang>.html), tocam
var/.problems-dirty e auditam statement-langs
(2026-09-15). |
/contest/admin/rounds?contest=<c> |
GET/POST | Bearer (GET admin ou .cjudge; POST
só admin) |
rodadas: GET →
{active, rounds[], next, promote_ready:{ok, blockers:[{code,detail}]}}
(a rodada ATIVA é espelhada do conf a cada leitura — editar
em ⚙️ Configurações/📚 Problemas nunca diverge). POST
{action}: add
{slug,name?,kind?,start,end,freeze?} (rodada planejada;
kind ∈ warmup|official|extra; freeze tem de
cair na janela) · set
{slug,new_slug?,…} (renomeia e edita; a ativa vai direto p/
o conf — pelo OBJETO editado, senão o espelho conf→json
anularia a edição) · problems
{slug,problems[]} (guarda de problema privado = a MESMA de
Prova › Problemas e do wizard, problems_denied_for:
público, ou o dono do contest é
dono/colaborador/membro da org do problema — vale igual
p/ rodada ativa e planejada; 403 problem_denied com a
lista; era só dono-ou-público até 2026-09-14) ·
set aceita também
colors = cores de balão DA
RODADA (formato do balloons.json:
{A:"RRGGBB",…,enableSonic:bool}; 422
colors_invalid; null remove — a rodada volta a
herdar; {} não mexe): na rodada ATIVA grava o
balloons.json na hora (= Evento › Balões, que o GET espelha
em colors); na planejada fica no plano e entra no ar na
promoção — sem colors, a promoção mantém as cores
em vigor; o arquivo
rounds/<slug>/balloons.json guarda as cores que a
rodada usou · remove {slug}
(só pending — arquivada é auditoria) ·
publish {slug,on} ·
promote {to?,force?}.
Promover = arquivar a rodada ativa + zerar o store +
aplicar a janela/PROBS da próxima; recusa com 409
not_ready + blockers
(round_running, jobs_in_flight,
pending_verdicts, review_pending,
judged_down, no_next_round,
shared_users), freeze_locked
= placar congelado antes de freeze_release_at — fim geral +
1 min; force:true ignora todos menos
no_next_round/freeze_locked. Tudo auditado
(round-add/set/problems/remove/publish/promote[-forced]
Problema na rodada (action:problems): a
guarda é problems_denied_for com o DONO do contest como
sujeito e a recusa é 404 problem_denied sem listar
ids (existência de privado alheio não vaza).
action:set na rodada ATIVA passa por
freeze_change_guard (freeze 0/"00" ou freeze em
vigor empurrado p/ o futuro = descongelar ⇒ 409
freeze_locked). Bloqueador
problem_denied (DURO, force não
passa) na promoção: a rodada planejada tem problema privado que o dono
não pode ver — última porta antes de cc_build_probs
materializar o enunciado (a lista pode ter vindo do spec
unificado/duplicate). (2026-09-15) |